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.'}