From bfedd37d52bfc0adb048c0f3a8edeb8748a4b12f Mon Sep 17 00:00:00 2001 From: Jeremiah <42397676+jlsnow301@users.noreply.github.com> Date: Thu, 14 Dec 2023 10:01:48 -0800 Subject: [PATCH] Typescript textarea component (#80276) ## About The Pull Request This refactor has been on my radar for a long time. There are oddities between this and the input component that are frustrating to work with. An issue was brought to my attention with the Interview panel which sends byond data on EVERY keystroke. This has been changed, documented, and made into typescript. It now sends onEnter, and the user is informed that they must press enter to submit.. It should be more obvious what the events for textarea _do_ actually so as to not make similar mistakes again. ## Why It's Good For The Game Less laggy input More typescript components/better dev exp ## Changelog :cl: fix: Admin interview panel should feel snappier. /:cl: --- tgui/packages/tgui/components/TextArea.jsx | 240 ------------------ tgui/packages/tgui/components/TextArea.tsx | 181 +++++++++++++ tgui/packages/tgui/interfaces/AdminFax.jsx | 2 +- tgui/packages/tgui/interfaces/AdminPDA.jsx | 2 +- .../tgui/interfaces/CommandReport.tsx | 2 +- .../tgui/interfaces/CommunicationsConsole.jsx | 2 +- .../{Interview.jsx => Interview.tsx} | 124 +++++---- tgui/packages/tgui/interfaces/LingMMITalk.tsx | 2 +- .../tgui/interfaces/LuaEditor/index.jsx | 2 +- tgui/packages/tgui/interfaces/MafiaPanel.tsx | 4 +- .../tgui/interfaces/NtosMessenger/index.tsx | 2 +- tgui/packages/tgui/interfaces/NtosNotepad.tsx | 6 +- tgui/packages/tgui/interfaces/PaperSheet.tsx | 4 +- .../tgui/interfaces/TextInputModal.tsx | 2 +- 14 files changed, 270 insertions(+), 305 deletions(-) delete mode 100644 tgui/packages/tgui/components/TextArea.jsx create mode 100644 tgui/packages/tgui/components/TextArea.tsx rename tgui/packages/tgui/interfaces/{Interview.jsx => Interview.tsx} (57%) diff --git a/tgui/packages/tgui/components/TextArea.jsx b/tgui/packages/tgui/components/TextArea.jsx deleted file mode 100644 index 560915ffac1..00000000000 --- a/tgui/packages/tgui/components/TextArea.jsx +++ /dev/null @@ -1,240 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @author Warlockd - * @license MIT - */ - -import { classes } from 'common/react'; -import { Component, createRef } from 'react'; -import { Box } from './Box'; -import { toInputValue } from './Input'; -import { KEY_ENTER, KEY_ESCAPE, KEY_TAB } from 'common/keycodes'; - -export class TextArea extends Component { - constructor(props) { - super(props); - this.textareaRef = props.innerRef || createRef(); - this.state = { - editing: false, - scrolledAmount: 0, - }; - const { dontUseTabForIndent = false } = props; - this.handleOnInput = (e) => { - const { editing } = this.state; - const { onInput } = this.props; - if (!editing) { - this.setEditing(true); - } - if (onInput) { - onInput(e, e.target.value); - } - }; - this.handleOnChange = (e) => { - const { editing } = this.state; - const { onChange } = this.props; - if (editing) { - this.setEditing(false); - } - if (onChange) { - onChange(e, e.target.value); - } - }; - this.handleKeyPress = (e) => { - const { editing } = this.state; - const { onKeyPress } = this.props; - if (!editing) { - this.setEditing(true); - } - if (onKeyPress) { - onKeyPress(e, e.target.value); - } - }; - this.handleKeyDown = (e) => { - const { editing } = this.state; - const { onChange, onInput, onEnter, onKey } = this.props; - if (e.keyCode === KEY_ENTER) { - this.setEditing(false); - if (onChange) { - onChange(e, e.target.value); - } - if (onInput) { - onInput(e, e.target.value); - } - if (onEnter) { - onEnter(e, e.target.value); - } - if (this.props.selfClear) { - e.target.value = ''; - e.target.blur(); - } - return; - } - if (e.keyCode === KEY_ESCAPE) { - if (this.props.onEscape) { - this.props.onEscape(e); - } - this.setEditing(false); - if (this.props.selfClear) { - e.target.value = ''; - } else { - e.target.value = toInputValue(this.props.value); - e.target.blur(); - } - return; - } - if (!editing) { - this.setEditing(true); - } - // Custom key handler - if (onKey) { - onKey(e, e.target.value); - } - if (!dontUseTabForIndent) { - const keyCode = e.keyCode || e.which; - if (keyCode === KEY_TAB) { - e.preventDefault(); - const { value, selectionStart, selectionEnd } = e.target; - e.target.value = - value.substring(0, selectionStart) + - '\t' + - value.substring(selectionEnd); - e.target.selectionEnd = selectionStart + 1; - if (onInput) { - onInput(e, e.target.value); - } - } - } - }; - this.handleFocus = (e) => { - const { editing } = this.state; - if (!editing) { - this.setEditing(true); - } - }; - this.handleBlur = (e) => { - const { editing } = this.state; - const { onChange } = this.props; - if (editing) { - this.setEditing(false); - if (onChange) { - onChange(e, e.target.value); - } - } - }; - this.handleScroll = (e) => { - const { displayedValue } = this.props; - const input = this.textareaRef.current; - if (displayedValue && input) { - this.setState({ - scrolledAmount: input.scrollTop, - }); - } - }; - } - - componentDidMount() { - const nextValue = this.props.value; - const input = this.textareaRef.current; - if (input) { - input.value = toInputValue(nextValue); - } - if (this.props.autoFocus || this.props.autoSelect) { - setTimeout(() => { - input.focus(); - - if (this.props.autoSelect) { - input.select(); - } - }, 1); - } - } - - componentDidUpdate(prevProps, prevState) { - const prevValue = prevProps.value; - const nextValue = this.props.value; - const input = this.textareaRef.current; - if (input && typeof nextValue === 'string' && prevValue !== nextValue) { - input.value = toInputValue(nextValue); - } - } - - setEditing(editing) { - this.setState({ editing }); - } - - getValue() { - return this.textareaRef.current && this.textareaRef.current.value; - } - - render() { - // Input only props - const { - onChange, - onKeyDown, - onKeyPress, - onInput, - onFocus, - onBlur, - onEnter, - value, - maxLength, - placeholder, - scrollbar, - noborder, - displayedValue, - ...boxProps - } = this.props; - - // Box props - const { className, fluid, nowrap, ...rest } = boxProps; - const { scrolledAmount } = this.state; - return ( - - {!!displayedValue && ( - - - {displayedValue} - - - )} - - - ); - } -} diff --git a/tgui/packages/tgui/components/TextArea.tsx b/tgui/packages/tgui/components/TextArea.tsx new file mode 100644 index 00000000000..cc6376d0de2 --- /dev/null +++ b/tgui/packages/tgui/components/TextArea.tsx @@ -0,0 +1,181 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @author Warlockd + * @license MIT + */ + +import { classes } from 'common/react'; +import { + forwardRef, + useEffect, + useState, + RefObject, + useRef, + useImperativeHandle, +} from 'react'; +import { toInputValue } from './Input'; +import { KEY } from 'common/keys'; +import { Box, BoxProps } from './Box'; +import { ChangeEvent, KeyboardEvent } from 'react'; + +type Props = Partial<{ + autoFocus: boolean; + autoSelect: boolean; + displayedValue: string; + dontUseTabForIndent: boolean; + maxLength: number; + noborder: boolean; + // This fires when: value changes + onChange: (event: ChangeEvent, value: string) => void; + // This fires when: enter is pressed + onEnter: (event: KeyboardEvent, value: string) => void; + // This fires when: escape is pressed + onEscape: (event: KeyboardEvent) => void; + placeholder: string; + scrollbar: boolean; + selfClear: boolean; + value: string; +}> & + BoxProps; + +export const TextArea = forwardRef( + (props: Props, forwardedRef: RefObject) => { + const { + autoFocus, + autoSelect, + displayedValue, + dontUseTabForIndent, + maxLength, + noborder, + onChange, + onEnter, + onEscape, + placeholder, + scrollbar, + selfClear, + value, + ...boxProps + } = props; + const { className, fluid, nowrap, ...rest } = boxProps; + + const textareaRef = useRef(null); + const [scrolledAmount, setScrolledAmount] = useState(0); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === KEY.Enter) { + if (event.shiftKey) { + onChange?.(event as any, event.currentTarget.value); + return; + } + + onEnter?.(event, event.currentTarget.value); + if (selfClear) { + event.currentTarget.value = ''; + } + event.currentTarget.blur(); + + return; + } + + if (event.key === KEY.Escape) { + onEscape?.(event); + if (selfClear) { + event.currentTarget.value = ''; + } else { + event.currentTarget.value = toInputValue(value); + event.currentTarget.blur(); + } + + return; + } + + if (!dontUseTabForIndent && event.key === KEY.Tab) { + event.preventDefault(); + const { value, selectionStart, selectionEnd } = event.currentTarget; + event.currentTarget.value = + value.substring(0, selectionStart) + + '\t' + + value.substring(selectionEnd); + event.currentTarget.selectionEnd = selectionStart + 1; + } + }; + + useImperativeHandle( + forwardedRef, + () => textareaRef.current as HTMLTextAreaElement, + ); + + useEffect(() => { + const input = textareaRef.current; + if (!input) return; + + input.value = toInputValue(value); + + if (autoFocus || autoSelect) { + setTimeout(() => { + input.focus(); + + if (autoSelect) { + input.select(); + } + }, 1); + } + }, []); + + return ( + + {!!displayedValue && ( + + + {displayedValue} + + + )} + onChange?.(event, event.target.value)} + onKeyDown={handleKeyDown} + onScroll={() => { + if (displayedValue && textareaRef.current) { + setScrolledAmount(textareaRef.current.scrollTop); + } + }} + placeholder={placeholder} + ref={textareaRef} + style={{ + color: displayedValue ? 'rgba(0, 0, 0, 0)' : 'inherit', + }} + /> + + ); + }, +); diff --git a/tgui/packages/tgui/interfaces/AdminFax.jsx b/tgui/packages/tgui/interfaces/AdminFax.jsx index 0f17a6c6083..47ab1d93660 100644 --- a/tgui/packages/tgui/interfaces/AdminFax.jsx +++ b/tgui/packages/tgui/interfaces/AdminFax.jsx @@ -140,7 +140,7 @@ export const FaxMainPanel = (props) => { placeholder="Your message here..." height="200px" value={rawText} - onInput={(e, value) => { + onChange={(e, value) => { setRawText(value); }} /> diff --git a/tgui/packages/tgui/interfaces/AdminPDA.jsx b/tgui/packages/tgui/interfaces/AdminPDA.jsx index 5bac1a319d5..fb800dd8200 100644 --- a/tgui/packages/tgui/interfaces/AdminPDA.jsx +++ b/tgui/packages/tgui/interfaces/AdminPDA.jsx @@ -125,7 +125,7 @@ const MessageInput = (props) => { placeholder="Type the message you want to send..." height="200px" mb={1} - onInput={(e, value) => { + onChange={(e, value) => { setMessageText(value); }} /> diff --git a/tgui/packages/tgui/interfaces/CommandReport.tsx b/tgui/packages/tgui/interfaces/CommandReport.tsx index 3ca782b0585..f2e15909cd0 100644 --- a/tgui/packages/tgui/interfaces/CommandReport.tsx +++ b/tgui/packages/tgui/interfaces/CommandReport.tsx @@ -164,7 +164,7 @@ const ReportText = (props) => { setCommandReport(value)} + onChange={(_, value) => setCommandReport(value)} value={commandReport} /> diff --git a/tgui/packages/tgui/interfaces/CommunicationsConsole.jsx b/tgui/packages/tgui/interfaces/CommunicationsConsole.jsx index 91deb18465d..564f2a200b1 100644 --- a/tgui/packages/tgui/interfaces/CommunicationsConsole.jsx +++ b/tgui/packages/tgui/interfaces/CommunicationsConsole.jsx @@ -84,7 +84,7 @@ const MessageModal = (props) => { width="80vw" backgroundColor="black" textColor="white" - onInput={(_, value) => { + onChange={(_, value) => { setInput(value.substring(0, maxMessageLength)); }} value={input} diff --git a/tgui/packages/tgui/interfaces/Interview.jsx b/tgui/packages/tgui/interfaces/Interview.tsx similarity index 57% rename from tgui/packages/tgui/interfaces/Interview.jsx rename to tgui/packages/tgui/interfaces/Interview.tsx index 399ae332660..4c996ac3651 100644 --- a/tgui/packages/tgui/interfaces/Interview.jsx +++ b/tgui/packages/tgui/interfaces/Interview.tsx @@ -7,54 +7,62 @@ import { } from '../components'; import { Window } from '../layouts'; import { useBackend } from '../backend'; +import { ReactNode } from 'react'; + +type Data = { + connected: boolean; + is_admin: boolean; + questions: Question[]; + queue_pos: number; + read_only: boolean; + status: string; + welcome_message: string; +}; + +type Question = { + qidx: number; + question: string; + response: string; +}; + +enum STATUS { + Approved = 'interview_approved', + Denied = 'interview_denied', +} + +// Matches a complete markdown-style link, capturing the whole [...](...) +const linkRegex = /(\[[^[]+\]\([^)]+\))/; +// Decomposes a markdown-style link into the link and display text +const linkDecomposeRegex = /\[([^[]+)\]\(([^)]+)\)/; + +// Renders any markdown-style links within a provided body of text +const linkifyText = (text: string) => { + let parts: ReactNode[] = text.split(linkRegex); + for (let i = 1; i < parts.length; i += 2) { + const match = linkDecomposeRegex.exec(parts[i] as string); + if (!match) continue; + + parts[i] = ( + + {match[1]} + + ); + } + return parts; +}; export const Interview = (props) => { - const { act, data } = useBackend(); + const { act, data } = useBackend(); const { - welcome_message, - questions, - read_only, - queue_pos, - is_admin, - status, connected, + is_admin, + questions = [], // TODO: Remove default + queue_pos, + read_only, + status, + welcome_message = '', } = data; - const rendered_status = (status) => { - switch (status) { - case 'interview_approved': - return This interview was approved.; - case 'interview_denied': - return This interview was denied.; - default: - return ( - - Your answers have been submitted. You are position {queue_pos} in - queue. - - ); - } - }; - - // Matches a complete markdown-style link, capturing the whole [...](...) - const link_regex = /(\[[^[]+\]\([^)]+\))/; - // Decomposes a markdown-style link into the link and display text - const link_decompose_regex = /\[([^[]+)\]\(([^)]+)\)/; - - // Renders any markdown-style links within a provided body of text - const linkify_text = (text) => { - let parts = text.split(link_regex); - for (let i = 1; i < parts.length; i += 2) { - const match = link_decompose_regex.exec(parts[i]); - parts[i] = ( - - {match[1]} - - ); - } - return parts; - }; - return ( { {(!read_only && ( - {linkify_text(welcome_message)} + {linkifyText(welcome_message)} - )) || - rendered_status(status)} + )) || } { )} {questions.map(({ qidx, question, response }) => ( - {linkify_text(question)} + {linkifyText(question)} {((read_only || is_admin) && ( {response || 'No response.'} )) || ( @@ -119,11 +126,10 @@ export const Interview = (props) => { fluid height={10} maxLength={500} - placeholder="Write your response here, max of 500 characters." - onChange={(e, input) => - input !== response && + placeholder="Write your response here, max of 500 characters. Press enter to submit." + onEnter={(e, input) => act('update_answer', { - qidx: qidx, + qidx, answer: input, }) } @@ -136,3 +142,21 @@ export const Interview = (props) => { ); }; + +const RenderedStatus = (props: { status: string; queue_pos: number }) => { + const { status, queue_pos } = props; + + switch (status) { + case STATUS.Approved: + return This interview was approved.; + case STATUS.Denied: + return This interview was denied.; + default: + return ( + + Your answers have been submitted. You are position {queue_pos} in + queue. + + ); + } +}; diff --git a/tgui/packages/tgui/interfaces/LingMMITalk.tsx b/tgui/packages/tgui/interfaces/LingMMITalk.tsx index 8beb4836c8e..3cbd0d3af7f 100644 --- a/tgui/packages/tgui/interfaces/LingMMITalk.tsx +++ b/tgui/packages/tgui/interfaces/LingMMITalk.tsx @@ -30,7 +30,7 @@ export const LingMMITalk = (props) => { setmmiMessage(value)} + onChange={(_, value) => setmmiMessage(value)} value={mmiMessage} /> diff --git a/tgui/packages/tgui/interfaces/LuaEditor/index.jsx b/tgui/packages/tgui/interfaces/LuaEditor/index.jsx index 80e787a197c..3a80487c2b2 100644 --- a/tgui/packages/tgui/interfaces/LuaEditor/index.jsx +++ b/tgui/packages/tgui/interfaces/LuaEditor/index.jsx @@ -200,7 +200,7 @@ export class LuaEditor extends Component { height="100%" value={scriptInput} fontFamily="Consolas" - onInput={(_, value) => + onChange={(_, value) => this.setState({ scriptInput: value }) } displayedValue={ diff --git a/tgui/packages/tgui/interfaces/MafiaPanel.tsx b/tgui/packages/tgui/interfaces/MafiaPanel.tsx index 5df5bccc61c..5437f2dfb82 100644 --- a/tgui/packages/tgui/interfaces/MafiaPanel.tsx +++ b/tgui/packages/tgui/interfaces/MafiaPanel.tsx @@ -201,7 +201,7 @@ const MafiaChat = (props) => { maxLength={300} className="Section__title candystripe" onChange={(e, value) => setMessagingBox(value)} - placeholder={'Type to chat'} + placeholder="Type to chat" value={message_to_send} /> @@ -382,7 +382,7 @@ const MafiaNotesTab = (props) => { maxLength={600} className="Section__title candystripe" onChange={(_, value) => setNotesMessage(value)} - placeholder={'Insert Notes...'} + placeholder="Insert Notes..." value={note_message} /> diff --git a/tgui/packages/tgui/interfaces/NtosMessenger/index.tsx b/tgui/packages/tgui/interfaces/NtosMessenger/index.tsx index 990cc710742..69796a73419 100644 --- a/tgui/packages/tgui/interfaces/NtosMessenger/index.tsx +++ b/tgui/packages/tgui/interfaces/NtosMessenger/index.tsx @@ -329,7 +329,7 @@ const SendToAllSection = (props) => { height={6} value={message} placeholder="Send message to everyone..." - onInput={(_: any, v: string) => setmessage(v)} + onChange={(event, value: string) => setmessage(value)} /> > diff --git a/tgui/packages/tgui/interfaces/NtosNotepad.tsx b/tgui/packages/tgui/interfaces/NtosNotepad.tsx index 41f5c9da6a1..170842a962a 100644 --- a/tgui/packages/tgui/interfaces/NtosNotepad.tsx +++ b/tgui/packages/tgui/interfaces/NtosNotepad.tsx @@ -307,9 +307,9 @@ class NotePadTextArea extends Component { return ( setText(value)} - className={'NtosNotepad__textarea'} + ref={this.innerRef} + onChange={(_, value) => setText(value)} + className="NtosNotepad__textarea" scroll nowrap={!wordWrap} value={text} diff --git a/tgui/packages/tgui/interfaces/PaperSheet.tsx b/tgui/packages/tgui/interfaces/PaperSheet.tsx index 3f6bf0de671..e99cecb5090 100644 --- a/tgui/packages/tgui/interfaces/PaperSheet.tsx +++ b/tgui/packages/tgui/interfaces/PaperSheet.tsx @@ -387,9 +387,9 @@ export class PrimaryView extends Component { textColor={useColor} fontFamily={useFont} bold={useBold} - height={'100%'} + height="100%" backgroundColor={paper_color} - onInput={(e, text) => { + onChange={(e, text) => { setTextAreaText(text); if (this.scrollableRef.current) { diff --git a/tgui/packages/tgui/interfaces/TextInputModal.tsx b/tgui/packages/tgui/interfaces/TextInputModal.tsx index 274927b2e46..63abe5f8413 100644 --- a/tgui/packages/tgui/interfaces/TextInputModal.tsx +++ b/tgui/packages/tgui/interfaces/TextInputModal.tsx @@ -117,7 +117,7 @@ const InputArea = (props: { event.preventDefault(); act('submit', { entry: input }); }} - onInput={(_, value) => onType(value)} + onChange={(_, value) => onType(value)} placeholder="Type something..." value={input} />
{linkify_text(welcome_message)}
{linkifyText(welcome_message)}
{linkify_text(question)}
{linkifyText(question)}
{response || 'No response.'}