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
🆑
fix: Admin interview panel should feel snappier.
/🆑
This commit is contained in:
Jeremiah
2023-12-14 19:01:48 +01:00
committed by GitHub
parent 6ecd739dca
commit bfedd37d52
14 changed files with 270 additions and 305 deletions
-240
View File
@@ -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 (
<Box
className={classes([
'TextArea',
fluid && 'TextArea--fluid',
noborder && 'TextArea--noborder',
className,
])}
{...rest}
>
{!!displayedValue && (
<Box position="absolute" width="100%" height="100%" overflow="hidden">
<div
className={classes([
'TextArea__textarea',
'TextArea__textarea_custom',
])}
style={{
transform: `translateY(-${scrolledAmount}px)`,
}}
>
{displayedValue}
</div>
</Box>
)}
<textarea
ref={this.textareaRef}
className={classes([
'TextArea__textarea',
scrollbar && 'TextArea__textarea--scrollable',
nowrap && 'TextArea__nowrap',
])}
placeholder={placeholder}
onChange={this.handleOnChange}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
onInput={this.handleOnInput}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onScroll={this.handleScroll}
maxLength={maxLength}
style={{
color: displayedValue ? 'rgba(0, 0, 0, 0)' : 'inherit',
}}
/>
</Box>
);
}
}
+181
View File
@@ -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<HTMLTextAreaElement>, value: string) => void;
// This fires when: enter is pressed
onEnter: (event: KeyboardEvent<HTMLTextAreaElement>, value: string) => void;
// This fires when: escape is pressed
onEscape: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
scrollbar: boolean;
selfClear: boolean;
value: string;
}> &
BoxProps;
export const TextArea = forwardRef(
(props: Props, forwardedRef: RefObject<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>(null);
const [scrolledAmount, setScrolledAmount] = useState(0);
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
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 (
<Box
className={classes([
'TextArea',
fluid && 'TextArea--fluid',
noborder && 'TextArea--noborder',
className,
])}
{...rest}
>
{!!displayedValue && (
<div
style={{
height: '100%',
overflow: 'hidden',
position: 'absolute',
width: '100%',
}}
>
<div
className={classes([
'TextArea__textarea',
'TextArea__textarea_custom',
])}
style={{
transform: `translateY(-${scrolledAmount}px)`,
}}
>
{displayedValue}
</div>
</div>
)}
<textarea
className={classes([
'TextArea__textarea',
scrollbar && 'TextArea__textarea--scrollable',
nowrap && 'TextArea__nowrap',
])}
maxLength={maxLength}
onChange={(event) => 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',
}}
/>
</Box>
);
},
);
+1 -1
View File
@@ -140,7 +140,7 @@ export const FaxMainPanel = (props) => {
placeholder="Your message here..."
height="200px"
value={rawText}
onInput={(e, value) => {
onChange={(e, value) => {
setRawText(value);
}}
/>
+1 -1
View File
@@ -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);
}}
/>
@@ -164,7 +164,7 @@ const ReportText = (props) => {
<TextArea
height="200px"
mb={1}
onInput={(_, value) => setCommandReport(value)}
onChange={(_, value) => setCommandReport(value)}
value={commandReport}
/>
<Stack vertical>
@@ -84,7 +84,7 @@ const MessageModal = (props) => {
width="80vw"
backgroundColor="black"
textColor="white"
onInput={(_, value) => {
onChange={(_, value) => {
setInput(value.substring(0, maxMessageLength));
}}
value={input}
@@ -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] = (
<a key={'link' + i} href={match[2]}>
{match[1]}
</a>
);
}
return parts;
};
export const Interview = (props) => {
const { act, data } = useBackend();
const { act, data } = useBackend<Data>();
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 <NoticeBox success>This interview was approved.</NoticeBox>;
case 'interview_denied':
return <NoticeBox danger>This interview was denied.</NoticeBox>;
default:
return (
<NoticeBox info>
Your answers have been submitted. You are position {queue_pos} in
queue.
</NoticeBox>
);
}
};
// 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] = (
<a key={'link' + i} href={match[2]}>
{match[1]}
</a>
);
}
return parts;
};
return (
<Window
width={500}
@@ -64,10 +72,9 @@ export const Interview = (props) => {
<Window.Content scrollable>
{(!read_only && (
<Section title="Welcome!">
<p>{linkify_text(welcome_message)}</p>
<p>{linkifyText(welcome_message)}</p>
</Section>
)) ||
rendered_status(status)}
)) || <RenderedStatus status={status} queue_pos={queue_pos} />}
<Section
title="Questionnaire"
buttons={
@@ -110,7 +117,7 @@ export const Interview = (props) => {
)}
{questions.map(({ qidx, question, response }) => (
<Section key={qidx} title={`Question ${qidx}`}>
<p>{linkify_text(question)}</p>
<p>{linkifyText(question)}</p>
{((read_only || is_admin) && (
<BlockQuote>{response || 'No response.'}</BlockQuote>
)) || (
@@ -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) => {
</Window>
);
};
const RenderedStatus = (props: { status: string; queue_pos: number }) => {
const { status, queue_pos } = props;
switch (status) {
case STATUS.Approved:
return <NoticeBox success>This interview was approved.</NoticeBox>;
case STATUS.Denied:
return <NoticeBox danger>This interview was denied.</NoticeBox>;
default:
return (
<NoticeBox info>
Your answers have been submitted. You are position {queue_pos} in
queue.
</NoticeBox>
);
}
};
@@ -30,7 +30,7 @@ export const LingMMITalk = (props) => {
<TextArea
height="60px"
placeholder="Send a message to have our decoy brain speak."
onInput={(_, value) => setmmiMessage(value)}
onChange={(_, value) => setmmiMessage(value)}
value={mmiMessage}
/>
</Stack.Item>
@@ -200,7 +200,7 @@ export class LuaEditor extends Component {
height="100%"
value={scriptInput}
fontFamily="Consolas"
onInput={(_, value) =>
onChange={(_, value) =>
this.setState({ scriptInput: value })
}
displayedValue={
+2 -2
View File
@@ -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}
/>
<Stack grow>
@@ -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}
/>
<Stack grow>
@@ -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)}
/>
</Section>
</>
@@ -307,9 +307,9 @@ class NotePadTextArea extends Component<NotePadTextAreaProps> {
return (
<TextArea
innerRef={this.innerRef}
onInput={(_, value) => setText(value)}
className={'NtosNotepad__textarea'}
ref={this.innerRef}
onChange={(_, value) => setText(value)}
className="NtosNotepad__textarea"
scroll
nowrap={!wordWrap}
value={text}
+2 -2
View File
@@ -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) {
@@ -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}
/>