Add undo/redo functionality #2591 (#2623)

* initial work - undo works except typed keys

* working but clunky code

* single letter and paste with no selection working

* add comments and keep the previous selection

* optimizations + add redo feature

* linting

---------

Co-authored-by: Mr Simon C <iamontheinternet@yahoo.com>
Co-authored-by: shatfield4 <seanhatfield5@gmail.com>
This commit is contained in:
Timothy Carambat 2024-11-12 15:49:10 -08:00 committed by GitHub
parent 2d59e30290
commit 2f56327962
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -18,6 +18,8 @@ import AttachItem from "./AttachItem";
import { PASTE_ATTACHMENT_EVENT } from "../DnDWrapper"; import { PASTE_ATTACHMENT_EVENT } from "../DnDWrapper";
export const PROMPT_INPUT_EVENT = "set_prompt_input"; export const PROMPT_INPUT_EVENT = "set_prompt_input";
const MAX_EDIT_STACK_SIZE = 100;
export default function PromptInput({ export default function PromptInput({
submit, submit,
onChange, onChange,
@ -32,14 +34,24 @@ export default function PromptInput({
const formRef = useRef(null); const formRef = useRef(null);
const textareaRef = useRef(null); const textareaRef = useRef(null);
const [_, setFocused] = useState(false); const [_, setFocused] = useState(false);
const undoStack = useRef([]);
const redoStack = useRef([]);
// To prevent too many re-renders we remotely listen for updates from the parent /**
// via an event cycle. Otherwise, using message as a prop leads to a re-render every * To prevent too many re-renders we remotely listen for updates from the parent
// change on the input. * via an event cycle. Otherwise, using message as a prop leads to a re-render every
* change on the input.
* @param {Event} e
*/
function handlePromptUpdate(e) { function handlePromptUpdate(e) {
setPromptInput(e?.detail ?? ""); setPromptInput(e?.detail ?? "");
} }
function resetTextAreaHeight() {
if (!textareaRef.current) return;
textareaRef.current.style.height = "auto";
}
useEffect(() => { useEffect(() => {
if (!!window) if (!!window)
window.addEventListener(PROMPT_INPUT_EVENT, handlePromptUpdate); window.addEventListener(PROMPT_INPUT_EVENT, handlePromptUpdate);
@ -48,51 +60,120 @@ export default function PromptInput({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!inputDisabled && textareaRef.current) { if (!inputDisabled && textareaRef.current) textareaRef.current.focus();
textareaRef.current.focus();
}
resetTextAreaHeight(); resetTextAreaHeight();
}, [inputDisabled]); }, [inputDisabled]);
const handleSubmit = (e) => { /**
* Save the current state before changes
* @param {number} adjustment
*/
function saveCurrentState(adjustment = 0) {
if (undoStack.current.length >= MAX_EDIT_STACK_SIZE)
undoStack.current.shift();
undoStack.current.push({
value: promptInput,
cursorPositionStart: textareaRef.current.selectionStart + adjustment,
cursorPositionEnd: textareaRef.current.selectionEnd + adjustment,
});
}
const debouncedSaveState = debounce(saveCurrentState, 250);
function handleSubmit(e) {
setFocused(false); setFocused(false);
submit(e); submit(e);
}; }
const resetTextAreaHeight = () => { function resetTextAreaHeight() {
if (textareaRef.current) { if (!textareaRef.current) return;
textareaRef.current.style.height = "auto"; textareaRef.current.style.height = "auto";
} }
};
const checkForSlash = (e) => { function checkForSlash(e) {
const input = e.target.value; const input = e.target.value;
if (input === "/") setShowSlashCommand(true); if (input === "/") setShowSlashCommand(true);
if (showSlashCommand) setShowSlashCommand(false); if (showSlashCommand) setShowSlashCommand(false);
return; return;
}; }
const watchForSlash = debounce(checkForSlash, 300);
const checkForAt = (e) => { function checkForAt(e) {
const input = e.target.value; const input = e.target.value;
if (input === "@") return setShowAgents(true); if (input === "@") return setShowAgents(true);
if (showAgents) return setShowAgents(false); if (showAgents) return setShowAgents(false);
}; }
const watchForAt = debounce(checkForAt, 300);
const captureEnter = (event) => { /**
if (event.keyCode == 13) { * Capture enter key press to handle submission, redo, or undo
if (!event.shiftKey) { * via keyboard shortcuts
submit(event); * @param {KeyboardEvent} event
} */
function captureEnterOrUndo(event) {
// Is simple enter key press w/o shift key
if (event.keyCode === 13 && !event.shiftKey) {
event.preventDefault();
return submit(event);
} }
};
const adjustTextArea = (event) => { // Is undo with Ctrl+Z or Cmd+Z + Shift key = Redo
if (
(event.ctrlKey || event.metaKey) &&
event.key === "z" &&
event.shiftKey
) {
event.preventDefault();
if (redoStack.current.length === 0) return;
const nextState = redoStack.current.pop();
if (!nextState) return;
undoStack.current.push({
value: promptInput,
cursorPositionStart: textareaRef.current.selectionStart,
cursorPositionEnd: textareaRef.current.selectionEnd,
});
setPromptInput(nextState.value);
setTimeout(() => {
textareaRef.current.setSelectionRange(
nextState.cursorPositionStart,
nextState.cursorPositionEnd
);
}, 0);
}
// Undo with Ctrl+Z or Cmd+Z
if (
(event.ctrlKey || event.metaKey) &&
event.key === "z" &&
!event.shiftKey
) {
if (undoStack.current.length === 0) return;
const lastState = undoStack.current.pop();
if (!lastState) return;
redoStack.current.push({
value: promptInput,
cursorPositionStart: textareaRef.current.selectionStart,
cursorPositionEnd: textareaRef.current.selectionEnd,
});
setPromptInput(lastState.value);
setTimeout(() => {
textareaRef.current.setSelectionRange(
lastState.cursorPositionStart,
lastState.cursorPositionEnd
);
}, 0);
}
}
function adjustTextArea(event) {
const element = event.target; const element = event.target;
element.style.height = "auto"; element.style.height = "auto";
element.style.height = `${element.scrollHeight}px`; element.style.height = `${element.scrollHeight}px`;
}; }
const handlePasteEvent = (e) => { function handlePasteEvent(e) {
e.preventDefault(); e.preventDefault();
if (e.clipboardData.items.length === 0) return false; if (e.clipboardData.items.length === 0) return false;
@ -140,10 +221,16 @@ export default function PromptInput({
}, 0); }, 0);
} }
return; return;
}; }
const watchForSlash = debounce(checkForSlash, 300); function handleChange(e) {
const watchForAt = debounce(checkForAt, 300); debouncedSaveState(-1);
onChange(e);
watchForSlash(e);
watchForAt(e);
adjustTextArea(e);
setPromptInput(e.target.value);
}
return ( return (
<div className="w-full fixed md:absolute bottom-0 left-0 z-10 md:z-0 flex justify-center items-center"> <div className="w-full fixed md:absolute bottom-0 left-0 z-10 md:z-0 flex justify-center items-center">
@ -168,15 +255,12 @@ export default function PromptInput({
<div className="flex items-center w-full border-b-2 border-gray-500/50"> <div className="flex items-center w-full border-b-2 border-gray-500/50">
<textarea <textarea
ref={textareaRef} ref={textareaRef}
onChange={(e) => { onChange={handleChange}
onChange(e); onKeyDown={captureEnterOrUndo}
watchForSlash(e); onPaste={(e) => {
watchForAt(e); saveCurrentState();
adjustTextArea(e); handlePasteEvent(e);
setPromptInput(e.target.value);
}} }}
onKeyDown={captureEnter}
onPaste={handlePasteEvent}
required={true} required={true}
disabled={inputDisabled} disabled={inputDisabled}
onFocus={() => setFocused(true)} onFocus={() => setFocused(true)}