Tgui say v1.1 (#75431)

## About The Pull Request
"It's better! I promise!"

When I wrote it, I was inexperienced and pretty angry. Not that I'm any
better of a person now, but the code should be. I consolidated instead
of relying on heavy abstractions. I simplified logic and wrote more
tests.

The result should look and feel much more like intended. The bundle size
is reduced by ~43%. Types are much stricter. The logic and css classes
are much more precise.

No major style changes yet
![Screenshot 2023-05-15
003339](https://github.com/tgstation/tgstation/assets/42397676/edeabdcf-5cc6-44ba-9e98-9015bb863547)

## Why It's Good For The Game
Less javascript is better, and being even a few fractions of a second
faster might make better gameplay
## Changelog
🆑
refactor: Tgui Say is rewritten, becoming "much more performant". Hey,
that's what it says on the tin! I'm not from marketing!
fix: Tguisay drag zones are now ever so slightly larger around the
corner of the window
fix: Pressing one of the chat open keys (T/Y/M/O) will no longer change
channels if it's already open
/🆑
This commit is contained in:
Jeremiah
2023-05-16 20:19:04 -06:00
committed by GitHub
parent ec7b9fe87d
commit aa74657f69
45 changed files with 885 additions and 1003 deletions
+2 -2
View File
@@ -117,10 +117,10 @@
close()
return TRUE
if (type == "thinking")
if(payload["mode"] == TRUE)
if(payload["visible"] == TRUE)
start_thinking()
return TRUE
if(payload["mode"] == FALSE)
if(payload["visible"] == FALSE)
stop_thinking()
return TRUE
return FALSE
+39
View File
@@ -0,0 +1,39 @@
/**
* ### Key codes.
* event.keyCode is deprecated, use this reference instead.
*
* Handles modifier keys (Shift, Alt, Control) and arrow keys.
*
* For alphabetical keys, use the actual character (e.g. 'a') instead of the key code.
*
* Something isn't here that you want? Just add it:
* @url https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
* @usage
* ```ts
* import { KEY } from 'tgui/common/keys';
*
* if (event.key === KEY.Enter) {
* // do something
* }
* ```
*/
export enum KEY {
Alt = 'Alt',
Backspace = 'Backspace',
Control = 'Control',
Delete = 'Delete',
Down = 'Down',
End = 'End',
Enter = 'Enter',
Escape = 'Esc',
Home = 'Home',
Insert = 'Insert',
Left = 'Left',
PageDown = 'PageDown',
PageUp = 'PageUp',
Right = 'Right',
Shift = 'Shift',
Space = ' ',
Tab = 'Tab',
Up = 'Up',
}
@@ -10,9 +10,13 @@
* called for N milliseconds. If `immediate` is passed, trigger the
* function on the leading edge, instead of the trailing.
*/
export const debounce = (fn, time, immediate = false) => {
let timeout;
return (...args) => {
export const debounce = <F extends (...args: any[]) => any>(
fn: F,
time: number,
immediate = false
): ((...args: Parameters<F>) => void) => {
let timeout: ReturnType<typeof setTimeout> | null;
return (...args: Parameters<F>) => {
const later = () => {
timeout = null;
if (!immediate) {
@@ -20,7 +24,7 @@ export const debounce = (fn, time, immediate = false) => {
}
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
clearTimeout(timeout!);
timeout = setTimeout(later, time);
if (callNow) {
fn(...args);
@@ -32,18 +36,24 @@ export const debounce = (fn, time, immediate = false) => {
* Returns a function, that, when invoked, will only be triggered at most once
* during a given window of time.
*/
export const throttle = (fn, time) => {
let previouslyRun, queuedToRun;
return function invokeFn(...args) {
export const throttle = <F extends (...args: any[]) => any>(
fn: F,
time: number
): ((...args: Parameters<F>) => void) => {
let previouslyRun: number | null,
queuedToRun: ReturnType<typeof setTimeout> | null;
return function invokeFn(...args: Parameters<F>) {
const now = Date.now();
queuedToRun = clearTimeout(queuedToRun);
if (queuedToRun) {
clearTimeout(queuedToRun);
}
if (!previouslyRun || now - previouslyRun >= time) {
fn.apply(null, args);
previouslyRun = now;
} else {
queuedToRun = setTimeout(
invokeFn.bind(null, ...args),
time - (now - previouslyRun)
() => invokeFn(...args),
time - (now - (previouslyRun ?? 0))
);
}
};
@@ -54,5 +64,5 @@ export const throttle = (fn, time) => {
*
* @param {number} time
*/
export const sleep = (time) =>
export const sleep = (time: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, time));
@@ -0,0 +1,47 @@
import { ChannelIterator } from './ChannelIterator';
describe('ChannelIterator', () => {
let channelIterator: ChannelIterator;
beforeEach(() => {
channelIterator = new ChannelIterator();
});
it('should cycle through channels properly', () => {
expect(channelIterator.current()).toBe('Say');
expect(channelIterator.next()).toBe('Radio');
expect(channelIterator.next()).toBe('Me');
expect(channelIterator.next()).toBe('OOC');
expect(channelIterator.next()).toBe('Say'); // Admin is blacklisted so it should be skipped
});
it('should set a channel properly', () => {
channelIterator.set('OOC');
expect(channelIterator.current()).toBe('OOC');
});
it('should return true when current channel is "Say"', () => {
channelIterator.set('Say');
expect(channelIterator.isSay()).toBe(true);
});
it('should return false when current channel is not "Say"', () => {
channelIterator.set('Radio');
expect(channelIterator.isSay()).toBe(false);
});
it('should return true when current channel is visible', () => {
channelIterator.set('Say');
expect(channelIterator.isVisible()).toBe(true);
});
it('should return false when current channel is not visible', () => {
channelIterator.set('OOC');
expect(channelIterator.isVisible()).toBe(false);
});
it('should not leak a message from a blacklisted channel', () => {
channelIterator.set('Admin');
expect(channelIterator.next()).toBe('Admin');
});
});
+50
View File
@@ -0,0 +1,50 @@
export type Channel = 'Say' | 'Radio' | 'Me' | 'OOC' | 'Admin';
/**
* ### ChannelIterator
* Cycles a predefined list of channels,
* skipping over blacklisted ones,
* and providing methods to manage and query the current channel.
*/
export class ChannelIterator {
private index: number = 0;
private readonly channels: Channel[] = ['Say', 'Radio', 'Me', 'OOC', 'Admin'];
private readonly blacklist: Channel[] = ['Admin'];
private readonly quiet: Channel[] = ['OOC', 'Admin'];
public next(): Channel {
if (this.blacklist.includes(this.channels[this.index])) {
return this.channels[this.index];
}
for (let index = 1; index <= this.channels.length; index++) {
let nextIndex = (this.index + index) % this.channels.length;
if (!this.blacklist.includes(this.channels[nextIndex])) {
this.index = nextIndex;
break;
}
}
return this.channels[this.index];
}
public set(channel: Channel): void {
this.index = this.channels.indexOf(channel) || 0;
}
public current(): Channel {
return this.channels[this.index];
}
public isSay(): boolean {
return this.channels[this.index] === 'Say';
}
public isVisible(): boolean {
return !this.quiet.includes(this.channels[this.index]);
}
public reset(): void {
this.index = 0;
}
}
@@ -0,0 +1,50 @@
import { ChatHistory } from './ChatHistory';
describe('ChatHistory', () => {
let chatHistory: ChatHistory;
beforeEach(() => {
chatHistory = new ChatHistory();
});
it('should add a message to the history', () => {
chatHistory.add('Hello');
expect(chatHistory.getOlderMessage()).toEqual('Hello');
});
it('should retrieve older and newer messages', () => {
chatHistory.add('Hello');
chatHistory.add('World');
expect(chatHistory.getOlderMessage()).toEqual('World');
expect(chatHistory.getOlderMessage()).toEqual('Hello');
expect(chatHistory.getNewerMessage()).toEqual('World');
expect(chatHistory.getNewerMessage()).toBeNull();
expect(chatHistory.getOlderMessage()).toEqual('World');
});
it('should limit the history to 5 messages', () => {
for (let i = 1; i <= 6; i++) {
chatHistory.add(`Message ${i}`);
}
expect(chatHistory.getOlderMessage()).toEqual('Message 6');
for (let i = 5; i >= 2; i--) {
expect(chatHistory.getOlderMessage()).toEqual(`Message ${i}`);
}
expect(chatHistory.getOlderMessage()).toBeNull();
});
it('should handle temp message correctly', () => {
chatHistory.saveTemp('Temp message');
expect(chatHistory.getTemp()).toEqual('Temp message');
expect(chatHistory.getTemp()).toBeNull();
});
it('should reset correctly', () => {
chatHistory.add('Hello');
chatHistory.getOlderMessage();
chatHistory.reset();
expect(chatHistory.isAtLatest()).toBe(true);
expect(chatHistory.getOlderMessage()).toEqual('Hello');
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* ### ChatHistory
* A class to manage a chat history,
* maintaining a maximum of five messages and supporting navigation,
* temporary message storage, and query operations.
*/
export class ChatHistory {
private messages: string[] = [];
private index: number = -1; // Initialize index at -1
private temp: string | null = null;
public add(message: string): void {
this.messages.unshift(message);
this.index = -1; // Reset index
if (this.messages.length > 5) {
this.messages.pop();
}
}
public getIndex(): number {
return this.index + 1;
}
public getOlderMessage(): string | null {
if (this.messages.length === 0 || this.index >= this.messages.length - 1) {
return null;
}
this.index++;
return this.messages[this.index];
}
public getNewerMessage(): string | null {
if (this.index <= 0) {
this.index = -1;
return null;
}
this.index--;
return this.messages[this.index];
}
public isAtLatest(): boolean {
return this.index === -1;
}
public saveTemp(message: string): void {
this.temp = message;
}
public getTemp(): string | null {
const temp = this.temp;
this.temp = null;
return temp;
}
public reset(): void {
this.index = -1;
this.temp = null;
}
}
+354
View File
@@ -0,0 +1,354 @@
import { Channel, ChannelIterator } from './ChannelIterator';
import { ChatHistory } from './ChatHistory';
import { Component, createRef, InfernoKeyboardEvent, RefObject } from 'inferno';
import { LINE_LENGTHS, RADIO_PREFIXES, WINDOW_SIZES } from './constants';
import { byondMessages } from './timers';
import { dragStartHandler } from 'tgui/drag';
import { windowOpen, windowLoad, windowClose, windowSet } from './helpers';
import { BooleanLike } from 'common/react';
import { KEY } from 'common/keys';
type ByondOpen = {
channel: Channel;
};
type ByondProps = {
maxLength: number;
lightMode: BooleanLike;
};
type State = {
buttonContent: string | number;
size: WINDOW_SIZES;
};
const CHANNEL_REGEX = /^:\w\s/;
export class TguiSay extends Component<{}, State> {
private channelIterator: ChannelIterator;
private chatHistory: ChatHistory;
private currentPrefix: keyof typeof RADIO_PREFIXES | null;
private innerRef: RefObject<HTMLTextAreaElement>;
private lightMode: boolean;
private maxLength: number;
private messages: typeof byondMessages;
state: State;
constructor(props: never) {
super(props);
this.channelIterator = new ChannelIterator();
this.chatHistory = new ChatHistory();
this.currentPrefix = null;
this.innerRef = createRef();
this.lightMode = false;
this.maxLength = 1024;
this.messages = byondMessages;
this.state = {
buttonContent: '',
size: WINDOW_SIZES.small,
};
this.handleArrowKeys = this.handleArrowKeys.bind(this);
this.handleBackspaceDelete = this.handleBackspaceDelete.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleEnter = this.handleEnter.bind(this);
this.handleForceSay = this.handleForceSay.bind(this);
this.handleIncrementChannel = this.handleIncrementChannel.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleProps = this.handleProps.bind(this);
this.reset = this.reset.bind(this);
this.setSize = this.setSize.bind(this);
this.setValue = this.setValue.bind(this);
}
componentDidMount() {
Byond.subscribeTo('props', this.handleProps);
Byond.subscribeTo('force', this.handleForceSay);
Byond.subscribeTo('open', this.handleOpen);
windowLoad();
}
handleArrowKeys(direction: KEY.Up | KEY.Down) {
const currentValue = this.innerRef.current?.value;
if (direction === KEY.Up) {
if (this.chatHistory.isAtLatest() && currentValue) {
// Save current message to temp history if at the most recent message
this.chatHistory.saveTemp(currentValue);
}
// Try to get the previous message, fall back to the current value if none
const prevMessage = this.chatHistory.getOlderMessage();
if (prevMessage) {
this.setState({ buttonContent: this.chatHistory.getIndex() });
this.setSize(prevMessage.length);
this.setValue(prevMessage);
}
} else {
const nextMessage =
this.chatHistory.getNewerMessage() || this.chatHistory.getTemp() || '';
const buttonContent = this.chatHistory.isAtLatest()
? this.channelIterator.current()
: this.chatHistory.getIndex();
this.setState({ buttonContent });
this.setSize(nextMessage.length);
this.setValue(nextMessage);
}
}
handleBackspaceDelete() {
const typed = this.innerRef.current?.value;
// User is on a chat history message
if (!this.chatHistory.isAtLatest()) {
this.chatHistory.reset();
this.setState({
buttonContent: this.currentPrefix ?? this.channelIterator.current(),
});
// Empty input, resets the channel
} else if (
!!this.currentPrefix &&
this.channelIterator.isSay() &&
typed?.length === 0
) {
this.currentPrefix = null;
this.setState({ buttonContent: this.channelIterator.current() });
}
this.setSize(typed?.length);
}
handleClose() {
const current = this.innerRef.current;
if (current) {
current.blur();
}
this.reset();
this.chatHistory.reset();
this.channelIterator.reset();
this.currentPrefix = null;
windowClose();
}
handleEnter() {
const prefix = this.currentPrefix ?? '';
const value = this.innerRef.current?.value;
if (value?.length && value.length < this.maxLength) {
this.chatHistory.add(value);
Byond.sendMessage('entry', {
channel: this.channelIterator.current(),
entry: this.channelIterator.isSay() ? prefix + value : value,
});
}
this.handleClose();
}
handleForceSay() {
const currentValue = this.innerRef.current?.value;
// Only force say if we're on a visible channel and have typed something
if (!currentValue || !this.channelIterator.isVisible()) return;
const prefix = this.currentPrefix ?? '';
const grunt = this.channelIterator.isSay()
? prefix + currentValue
: currentValue;
this.messages.forceSayMsg(grunt);
this.reset();
}
handleIncrementChannel() {
// Binary talk is a special case, tell byond to show thinking indicators
if (this.channelIterator.isSay() && this.currentPrefix === ':b ') {
this.messages.channelIncrementMsg(true);
}
this.currentPrefix = null;
this.channelIterator.next();
// If we've looped onto a quiet channel, tell byond to hide thinking indicators
if (!this.channelIterator.isVisible()) {
this.messages.channelIncrementMsg(false);
}
this.setState({ buttonContent: this.channelIterator.current() });
}
handleInput() {
const typed = this.innerRef.current?.value;
// If we're typing, send the message
if (this.channelIterator.isVisible() && this.currentPrefix !== ':b ') {
this.messages.typingMsg();
}
this.setSize(typed?.length);
// Is there a value? Is it long enough to be a prefix?
if (!typed || typed.length < 3) {
return;
}
if (!CHANNEL_REGEX.test(typed)) {
return;
}
// Is it a valid prefix?
const prefix = typed
.slice(0, 3)
?.toLowerCase() as keyof typeof RADIO_PREFIXES;
if (!RADIO_PREFIXES[prefix] || prefix === this.currentPrefix) {
return;
}
// If we're in binary, hide the thinking indicator
if (prefix === ':b ') {
Byond.sendMessage('thinking', { visible: false });
}
this.channelIterator.set('Say');
this.currentPrefix = prefix;
this.setState({ buttonContent: RADIO_PREFIXES[prefix] });
this.setValue(typed.slice(3));
}
handleKeyDown(event: InfernoKeyboardEvent<HTMLTextAreaElement>) {
switch (event.key) {
case KEY.Up:
case KEY.Down:
event.preventDefault();
this.handleArrowKeys(event.key);
break;
case KEY.Delete:
case KEY.Backspace:
this.handleBackspaceDelete();
break;
case KEY.Enter:
event.preventDefault();
this.handleEnter();
break;
case KEY.Tab:
event.preventDefault();
this.handleIncrementChannel();
break;
case KEY.Escape:
this.handleClose();
break;
}
}
handleOpen = (data: ByondOpen) => {
const { channel } = data;
// Catches the case where the modal is already open
if (this.channelIterator.isSay()) {
this.channelIterator.set(channel);
}
this.setState({ buttonContent: this.channelIterator.current() });
setTimeout(() => {
this.innerRef.current?.focus();
}, 1);
windowOpen(this.channelIterator.current());
};
handleProps = (data: ByondProps) => {
const { maxLength, lightMode } = data;
this.maxLength = maxLength;
this.lightMode = !!lightMode;
};
reset() {
this.setValue('');
this.setSize();
this.setState({
buttonContent: this.channelIterator.current(),
});
}
setSize(length = 0) {
let newSize: WINDOW_SIZES;
if (length > LINE_LENGTHS.medium) {
newSize = WINDOW_SIZES.large;
} else if (length <= LINE_LENGTHS.medium && length > LINE_LENGTHS.small) {
newSize = WINDOW_SIZES.medium;
} else {
newSize = WINDOW_SIZES.small;
}
if (this.state.size !== newSize) {
this.setState({ size: newSize });
windowSet(newSize);
}
}
setValue(value: string) {
const textArea = this.innerRef.current;
if (textArea) {
textArea.value = value;
}
}
render() {
const theme =
(this.lightMode && 'lightMode') ||
(this.currentPrefix && RADIO_PREFIXES[this.currentPrefix]) ||
this.channelIterator.current();
return (
<div
className={`window window-${theme} window-${this.state.size}`}
$HasKeyedChildren>
<Dragzone position="top" theme={theme} />
<div className="center" $HasKeyedChildren>
<Dragzone position="left" theme={theme} />
<div className="input" $HasKeyedChildren>
<button
className={`button button-${theme}`}
onClick={this.handleIncrementChannel}
type="button">
{this.state.buttonContent}
</button>
<textarea
className={`textarea textarea-${theme}`}
maxLength={this.maxLength}
onInput={this.handleInput}
onKeyDown={this.handleKeyDown}
ref={this.innerRef}
/>
</div>
<Dragzone position="right" theme={theme} />
</div>
<Dragzone position="bottom" theme={theme} />
</div>
);
}
}
const Dragzone = ({ theme, position }: { theme: string; position: string }) => {
// Horizontal or vertical?
const location =
position === 'left' || position === 'right' ? 'vertical' : 'horizontal';
return (
<div
className={`dragzone-${location} dragzone-${position} dragzone-${theme}`}
onmousedown={dragStartHandler}
/>
);
};
@@ -1,20 +0,0 @@
import { dragStartHandler } from 'tgui/drag';
import { DragzoneProps } from '../types';
/** Creates a draggable edge. Props Req: Location */
export const Dragzone = (props: Partial<DragzoneProps>) => {
const { theme } = props;
if (!theme) return null;
const direction =
(props.top && 'top') ||
(props.right && 'right') ||
(props.bottom && 'bottom') ||
(props.left && 'left');
return (
<div
className={`dragzone-${direction}-${theme}`}
onmousedown={dragStartHandler}
/>
);
};
+32
View File
@@ -0,0 +1,32 @@
/** Window sizes in pixels */
export enum WINDOW_SIZES {
small = 30,
medium = 50,
large = 70,
width = 231,
}
/** Line lengths for autoexpand */
export enum LINE_LENGTHS {
small = 22,
medium = 45,
}
/**
* Radio prefixes.
* Displays the name in the left button, tags a css class.
*/
export const RADIO_PREFIXES = {
':a ': 'Hive',
':b ': 'io',
':c ': 'Cmd',
':e ': 'Engi',
':m ': 'Med',
':n ': 'Sci',
':o ': 'AI',
':s ': 'Sec',
':t ': 'Synd',
':u ': 'Supp',
':v ': 'Svc',
':y ': 'CCom',
} as const;
@@ -1,73 +0,0 @@
/** Radio channels */
export const CHANNELS = ['Say', 'Radio', 'Me', 'OOC', 'Admin'] as const;
/** Window sizes in pixels */
export enum WINDOW_SIZES {
small = 30,
medium = 50,
large = 70,
width = 231,
}
/** Line lengths for autoexpand */
export enum LINE_LENGTHS {
small = 20,
medium = 35,
}
/**
* Radio prefixes.
* Contains the properties:
* id - string. css class identifier.
* label - string. button label.
*/
export const RADIO_PREFIXES = {
':a ': {
id: 'hive',
label: 'Hive',
},
':b ': {
id: 'binary',
label: '0101',
},
':c ': {
id: 'command',
label: 'Cmd',
},
':e ': {
id: 'engi',
label: 'Engi',
},
':m ': {
id: 'medical',
label: 'Med',
},
':n ': {
id: 'science',
label: 'Sci',
},
':o ': {
id: 'ai',
label: 'AI',
},
':s ': {
id: 'security',
label: 'Sec',
},
':t ': {
id: 'syndicate',
label: 'Syndi',
},
':u ': {
id: 'supply',
label: 'Supp',
},
':v ': {
id: 'service',
label: 'Svc',
},
':y ': {
id: 'centcom',
label: 'CCom',
},
} as const;
Binary file not shown.
@@ -1,22 +0,0 @@
import { KEY_DOWN, KEY_UP } from 'common/keycodes';
import { getHistoryLength } from '../helpers';
import { Modal } from '../types';
/** Increments the chat history counter, looping through entries */
export const handleArrowKeys = function (
this: Modal,
direction: number,
value: string
) {
const { historyCounter } = this.fields;
if (direction === KEY_UP && historyCounter < getHistoryLength()) {
if (!historyCounter) {
this.fields.tempHistory = value;
}
this.fields.historyCounter++;
this.events.onViewHistory();
} else if (direction === KEY_DOWN && historyCounter > 0) {
this.fields.historyCounter--;
this.events.onViewHistory();
}
};
@@ -1,22 +0,0 @@
import { CHANNELS } from '../constants';
import { Modal } from '../types';
/**
* 1. Resets history if editing a message
* 2. Backspacing while empty resets any radio subchannels
* 3. Ensures backspace and delete calculate window size
*/
export const handleBackspaceDelete = function (this: Modal) {
const { buttonContent, channel } = this.state;
const { radioPrefix, value } = this.fields;
// User is on a chat history message
if (typeof buttonContent === 'number') {
this.fields.historyCounter = 0;
this.setState({ buttonContent: CHANNELS[channel] });
}
if (!value?.length && radioPrefix) {
this.fields.radioPrefix = '';
this.setState({ buttonContent: CHANNELS[channel] });
}
this.events.onSetSize(value?.length);
};
@@ -1,9 +0,0 @@
import { Modal } from '../types';
/**
* User clicks the channel button.
* Simulates the tab key.
*/
export const handleClick = function (this: Modal) {
this.events.onIncrementChannel();
};
@@ -1,23 +0,0 @@
import { CHANNELS } from '../constants';
import { windowLoad, windowOpen } from '../helpers';
import { Modal } from '../types';
/** Attach listeners, sets window size just in case */
export const handleComponentMount = function (this: Modal) {
Byond.subscribeTo('props', (data) => {
this.fields.maxLength = data.maxLength;
this.fields.lightMode = !!data.lightMode;
});
Byond.subscribeTo('force', () => {
this.events.onForce();
});
Byond.subscribeTo('open', (data) => {
const channel = CHANNELS.indexOf(data.channel) || 0;
this.setState({ buttonContent: CHANNELS[channel], channel });
setTimeout(() => {
this.fields.innerRef.current?.focus();
}, 1);
windowOpen(CHANNELS[channel]);
});
windowLoad();
};
@@ -1,6 +0,0 @@
import { Modal } from '../types';
/** After updating the input value, sets back to false */
export const handleComponentUpdate = function (this: Modal) {
this.setState({ edited: false });
};
-23
View File
@@ -1,23 +0,0 @@
import { CHANNELS } from '../constants';
import { storeChat, windowClose } from '../helpers';
import { Modal } from '../types';
/** User presses enter. Closes if no value. */
export const handleEnter = function (
this: Modal,
event: KeyboardEvent,
value: string
) {
const { channel } = this.state;
const { maxLength, radioPrefix } = this.fields;
event.preventDefault();
if (value && value.length < maxLength) {
storeChat(value);
Byond.sendMessage('entry', {
channel: CHANNELS[channel],
entry: channel === 0 ? radioPrefix + value : value,
});
}
this.events.onReset();
windowClose();
};
@@ -1,8 +0,0 @@
import { windowClose } from '../helpers';
import { Modal } from '../types';
/** User presses escape, closes the window */
export const handleEscape = function (this: Modal) {
this.events.onReset();
windowClose();
};
-19
View File
@@ -1,19 +0,0 @@
import { CHANNELS, WINDOW_SIZES } from '../constants';
import { windowSet } from '../helpers';
import { Modal } from '../types';
/** Sends the current input to byond and purges it */
export const handleForce = function (this: Modal) {
const { channel, size } = this.state;
const { radioPrefix, value } = this.fields;
if (value && channel < 2) {
this.timers.forceDebounce({
channel: CHANNELS[channel],
entry: channel === 0 ? radioPrefix + value : value,
});
this.events.onReset(channel);
if (size !== WINDOW_SIZES.small) {
windowSet();
}
}
};
@@ -1,49 +0,0 @@
import { CHANNELS } from '../constants';
import { Modal } from '../types';
// Insert the names of channels you want to not cycle on tab here
const BLACKLIST = ['Admin'];
const BLACKLISTED_CHANNEL_INDICES = CHANNELS.map((channel, index) => {
if (BLACKLIST.includes(channel)) {
return index;
}
}).filter((x) => x !== undefined);
/**
* Increments the channel or resets to the beginning of the list.
* If the user switches between IC/OOC, messages Byond to toggle thinking
* indicators.
*/
export const handleIncrementChannel = function (this: Modal) {
let { channel } = this.state;
const { radioPrefix } = this.fields;
if (radioPrefix === ':b ') {
this.timers.channelDebounce({ mode: true });
}
this.fields.radioPrefix = '';
if (BLACKLISTED_CHANNEL_INDICES.includes(channel)) {
return;
}
if (BLACKLISTED_CHANNEL_INDICES.length === CHANNELS.length) {
this.setState({
buttonContent: CHANNELS[channel],
channel,
});
return;
}
do {
channel++;
if (channel === CHANNELS.length) {
this.timers.channelDebounce({ mode: true });
channel = 0;
}
} while (BLACKLISTED_CHANNEL_INDICES.includes(channel));
if (channel === CHANNELS.indexOf('OOC')) {
// Disables thinking indicator for OOC channel
this.timers.channelDebounce({ mode: false });
}
this.setState({
buttonContent: CHANNELS[channel],
channel,
});
};
-41
View File
@@ -1,41 +0,0 @@
import { handleArrowKeys } from './arrowKeys';
import { handleBackspaceDelete } from './backspaceDelete';
import { handleComponentMount } from './componentMount';
import { handleComponentUpdate } from './componentUpdate';
import { handleClick } from './click';
import { handleEnter } from './enter';
import { handleEscape } from './escape';
import { handleForce } from './force';
import { handleIncrementChannel } from './incrementChannel';
import { handleInput } from './input';
import { handleKeyDown } from './keyDown';
import { handleRadioPrefix } from './radioPrefix';
import { handleReset } from './reset';
import { handleSetSize } from './setSize';
import { handleViewHistory } from './viewHistory';
import { Modal } from '../types';
/**
* Maps all TGUI say events with their associated handlers.
*
* return -- object: events
*/
export const eventHandlerMap = (parent: Modal): Modal['events'] => {
return {
onArrowKeys: handleArrowKeys.bind(parent),
onBackspaceDelete: handleBackspaceDelete.bind(parent),
onClick: handleClick.bind(parent),
onComponentMount: handleComponentMount.bind(parent),
onComponentUpdate: handleComponentUpdate.bind(parent),
onEnter: handleEnter.bind(parent),
onEscape: handleEscape.bind(parent),
onForce: handleForce.bind(parent),
onIncrementChannel: handleIncrementChannel.bind(parent),
onInput: handleInput.bind(parent),
onKeyDown: handleKeyDown.bind(parent),
onRadioPrefix: handleRadioPrefix.bind(parent),
onReset: handleReset.bind(parent),
onSetSize: handleSetSize.bind(parent),
onViewHistory: handleViewHistory.bind(parent),
};
};
-11
View File
@@ -1,11 +0,0 @@
import { Modal } from '../types';
/**
* Grabs input and sets size, force values etc.
* Input value only triggers a rerender on setEdited.
*/
export const handleInput = function (this: Modal, _, value: string) {
this.fields.value = value;
this.events.onRadioPrefix();
this.events.onSetSize(value.length);
};
@@ -1,43 +0,0 @@
import { KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_TAB, KEY_UP } from 'common/keycodes';
import { isAlphanumeric, getHistoryLength } from '../helpers';
import { Modal } from '../types';
/**
* Handles other key events.
* TAB - Changes channels.
* UP/DOWN - Sets history counter and input value.
* BKSP/DEL - Resets history counter and checks window size.
* TYPING - When users key, it tells byond that it's typing.
*/
export const handleKeyDown = function (
this: Modal,
event: KeyboardEvent,
value: string
) {
const { channel } = this.state;
const { radioPrefix } = this.fields;
if (!event.keyCode) {
return; // Really doubt it, but...
}
if (event.keyCode === KEY_UP || event.keyCode === KEY_DOWN) {
event.preventDefault();
if (getHistoryLength()) {
this.events.onArrowKeys(event.keyCode, value);
}
return;
}
if (event.keyCode === KEY_TAB) {
event.preventDefault();
this.events.onIncrementChannel();
return;
}
if (event.keyCode === KEY_DELETE || event.keyCode === KEY_BACKSPACE) {
this.events.onBackspaceDelete();
return;
}
if (isAlphanumeric(event.keyCode)) {
if (channel !== 3 && radioPrefix !== ':b ') {
this.timers.typingThrottle();
}
}
};
@@ -1,34 +0,0 @@
import { RADIO_PREFIXES } from '../constants';
import { Modal } from '../types';
/**
* Gets any channel prefixes from the chat bar
* and changes to the corresponding radio subchannel.
*
* Exemptions: Channel is OOC, value is too short,
* Not a valid radio pref, or value is already the radio pref.
*/
export const handleRadioPrefix = function (this: Modal) {
const { channel } = this.state;
const { radioPrefix, value } = this.fields;
if (channel > 1 || !value || value.length < 3) {
return;
}
const nextPrefix = value?.slice(0, 3)?.toLowerCase();
if (!RADIO_PREFIXES[nextPrefix] || radioPrefix === nextPrefix) {
return;
}
this.fields.value = value?.slice(3);
// Binary is a "secret" channel
if (nextPrefix === ':b ') {
Byond.sendMessage('thinking', { mode: false });
} else if (radioPrefix === ':b ' && nextPrefix !== ':b ') {
Byond.sendMessage('thinking', { mode: true });
}
this.fields.radioPrefix = nextPrefix;
this.setState({
buttonContent: RADIO_PREFIXES[nextPrefix]?.label,
channel: 0,
edited: true,
});
};
-22
View File
@@ -1,22 +0,0 @@
import { CHANNELS, WINDOW_SIZES } from '../constants';
import { valueExists } from '../helpers';
import { Modal } from '../types';
/**
* Resets window to default parameters.
*
* Parameters:
* channel - Optional. Sets the channel and thus the color scheme.
*/
export const handleReset = function (this: Modal, channel?: number) {
this.fields.historyCounter = 0;
this.fields.radioPrefix = '';
this.fields.tempHistory = '';
this.fields.value = '';
this.setState({
buttonContent: valueExists(channel) ? CHANNELS[channel!] : '',
channel: valueExists(channel) ? channel! : -1,
edited: true,
size: WINDOW_SIZES.small,
});
};
@@ -1,22 +0,0 @@
import { LINE_LENGTHS, WINDOW_SIZES } from '../constants';
import { windowSet } from '../helpers';
import { Modal } from '../types';
/** Adjusts window sized based on event.target.value */
export const handleSetSize = function (this: Modal, value: number) {
const { size } = this.state;
if (value > LINE_LENGTHS.medium && size !== WINDOW_SIZES.large) {
this.setState({ size: WINDOW_SIZES.large });
windowSet(WINDOW_SIZES.large);
} else if (
value <= LINE_LENGTHS.medium &&
value > LINE_LENGTHS.small &&
size !== WINDOW_SIZES.medium
) {
this.setState({ size: WINDOW_SIZES.medium });
windowSet(WINDOW_SIZES.medium);
} else if (value <= LINE_LENGTHS.small && size !== WINDOW_SIZES.small) {
this.setState({ size: WINDOW_SIZES.small });
windowSet(WINDOW_SIZES.small);
}
};
@@ -1,26 +0,0 @@
import { CHANNELS } from '../constants';
import { getHistoryAt, getHistoryLength } from '../helpers';
import { Modal } from '../types';
/** Sets the input value to chat history at index historyCounter. */
export const handleViewHistory = function (this: Modal) {
const { channel } = this.state;
const { historyCounter } = this.fields;
if (historyCounter > 0 && getHistoryLength()) {
this.fields.value = getHistoryAt(historyCounter);
if (channel < 2) {
this.timers.typingThrottle();
}
this.setState({ buttonContent: historyCounter, edited: true });
this.events.onSetSize(0);
} else {
/** Restores any saved history */
this.fields.value = this.fields.tempHistory;
this.fields.tempHistory = '';
this.setState({
buttonContent: CHANNELS[channel],
edited: true,
});
}
this.events.onSetSize(this.fields.value?.length);
};
+56
View File
@@ -0,0 +1,56 @@
import { Channel } from './ChannelIterator';
import { WINDOW_SIZES } from './constants';
/**
* Once byond signals this via keystroke, it
* ensures window size, visibility, and focus.
*/
export const windowOpen = (channel: Channel) => {
setWindowSizeAndVisibility(true, WINDOW_SIZES.small);
Byond.sendMessage('open', { channel });
};
/**
* Resets the state of the window and hides it from user view.
* Sending "close" logs it server side.
*/
export const windowClose = () => {
setWindowSizeAndVisibility(false, WINDOW_SIZES.small);
Byond.winset('map', {
focus: true,
});
Byond.sendMessage('close');
};
/** Some QoL to hide the window on load. Doesn't log this event */
export const windowLoad = () => {
Byond.winset('tgui_say', {
pos: '848,500',
});
setWindowSizeAndVisibility(false, WINDOW_SIZES.small);
Byond.winset('map', {
focus: true,
});
};
/**
* Modifies the window size.
*/
export const windowSet = (size = WINDOW_SIZES.small) => {
setWindowSizeAndVisibility(true, size);
};
/** Helper function to set window size and visibility */
const setWindowSizeAndVisibility = (isVisible: boolean, size: number) => {
const sizeStr = `${WINDOW_SIZES.width}x${size}`;
Byond.winset('tgui_say', {
'is-visible': isVisible,
size: sizeStr,
});
Byond.winset('tgui_say.browser', {
'is-visible': isVisible,
size: sizeStr,
});
};
-157
View File
@@ -1,157 +0,0 @@
import { CHANNELS, RADIO_PREFIXES, WINDOW_SIZES } from '../constants';
import { KEY_0, KEY_Z } from 'common/keycodes';
import { classes } from 'common/react';
import { debounce, throttle } from 'common/timer';
/**
* Window functions
*/
/**
* Once byond signals this via keystroke, it
* ensures window size, visibility, and focus.
*/
export const windowOpen = (channel: string) => {
setOpen();
Byond.sendMessage('open', { channel });
};
/**
* Resets the state of the window and hides it from user view.
* Sending "close" logs it server side.
*/
export const windowClose = () => {
setClosed();
Byond.sendMessage('close');
};
/** Some QoL to hide the window on load. Doesn't log this event */
export const windowLoad = () => {
Byond.winset('tgui_say', {
pos: '848,500',
});
setClosed();
};
/**
* Modifies the window size.
*
* Parameters:
* size - The size of the window in pixels. Optional.
*/
export const windowSet = (size: number = WINDOW_SIZES.small) => {
Byond.winset('tgui_say', { size: `${WINDOW_SIZES.width}x${size}` });
Byond.winset('tgui_say.browser', { size: `${WINDOW_SIZES.width}x${size}` });
};
/** Private functions */
/** Sets the skin props as opened. Focus might be a placebo here. */
const setOpen = () => {
Byond.winset('tgui_say', {
'is-visible': true,
size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
});
Byond.winset('tgui_say.browser', {
'is-visible': true,
size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
});
};
/** Sets the skin props as closed. */
const setClosed = () => {
Byond.winset('tgui_say', {
'is-visible': false,
size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
});
Byond.winset('map', {
focus: true,
});
};
/**
* Chat history functions
*/
/** Stores a list of chat messages entered as values */
let savedMessages: string[] = [];
/** Returns the chat history at specified index */
export const getHistoryAt = (index: number): string =>
savedMessages[savedMessages.length - index];
/**
* The length of chat history.
* I am absolutely being excessive, but whatever
*/
export const getHistoryLength = (): number => savedMessages.length;
/**
* Stores entries in the chat history.
* Deletes old entries if the list is too long.
*/
export const storeChat = (message: string): void => {
if (savedMessages.length === 5) {
savedMessages.shift();
}
savedMessages.push(message);
};
/** Miscellaneous */
/**
* Returns modular css classes.
*
* Parameters:
* element - required string. The element selector.
* theme - optional string. The theme to apply.
* options - optional string | number. Adds another css selector.
*/
export const getCss = (
element: string,
theme?: string,
options?: string | number
): string =>
classes([
element,
valueExists(theme) && `${element}-${theme}`,
valueExists(options) && `${element}-${options}`,
]);
/**
* Returns a string that represents the css selector to use.
* Light mode takes precedence over radioPrefixes,
* radioPrefixes takes precedence over channel.
*
* Parameters:
* lightMode - boolean. If true, returns the light mode selector.
* radioPrefix - string. If not empty, returns the radio prefix selector.
* channel - number. The channel to use.
*/
export const getTheme = (
lightMode: boolean,
radioPrefix: string,
channel: number
): string => {
return (
(lightMode && 'lightMode') ||
RADIO_PREFIXES[radioPrefix]?.id ||
CHANNELS[channel]?.toLowerCase()
);
};
/** Checks keycodes for alpha/numeric characters */
export const isAlphanumeric = (keyCode: number): boolean =>
keyCode >= KEY_0 && keyCode <= KEY_Z;
/** Timers: Prevents overloading the server, throttles messages */
export const timers = {
channelDebounce: debounce((mode) => Byond.sendMessage('thinking', mode), 400),
forceDebounce: debounce(
(entry) => Byond.sendMessage('force', entry),
1000,
true
),
typingThrottle: throttle(() => Byond.sendMessage('typing'), 4000),
};
/** Checks if a parameter is null or undefined. Returns bool */
export const valueExists = (param: any): boolean =>
param !== null && param !== undefined;
+1 -1
View File
@@ -1,6 +1,6 @@
import './styles/main.scss';
import { createRenderer } from 'tgui/renderer';
import { TguiSay } from './interfaces/TguiSay';
import { TguiSay } from './TguiSay';
const renderApp = createRenderer(() => {
return <TguiSay />;
@@ -1,76 +0,0 @@
import { TextArea } from 'tgui/components';
import { WINDOW_SIZES } from '../constants';
import { Dragzone } from '../components/dragzone';
import { eventHandlerMap } from '../handlers';
import { getCss, getTheme, timers } from '../helpers';
import { Component, createRef } from 'inferno';
import { Modal, State } from '../types';
/** Primary class for the TGUI say modal. */
export class TguiSay extends Component<{}, State> {
events: Modal['events'] = eventHandlerMap(this);
fields: Modal['fields'] = {
historyCounter: 0,
innerRef: createRef(),
lightMode: false,
maxLength: 1024,
radioPrefix: '',
tempHistory: '',
value: '',
};
state: Modal['state'] = {
buttonContent: '',
channel: -1,
edited: false,
size: WINDOW_SIZES.small,
};
timers: Modal['timers'] = timers;
componentDidMount() {
this.events.onComponentMount();
}
componentDidUpdate() {
if (this.state.edited) {
this.events.onComponentUpdate();
}
}
render() {
const { onClick, onEnter, onEscape, onKeyDown, onInput } = this.events;
const { innerRef, lightMode, maxLength, radioPrefix, value } = this.fields;
const { buttonContent, channel, edited, size } = this.state;
const theme = getTheme(lightMode, radioPrefix, channel);
return (
<div className={getCss('modal', theme, size)} $HasKeyedChildren>
<Dragzone theme={theme} top />
<div className="modal__content" $HasKeyedChildren>
<Dragzone theme={theme} left />
{!!theme && (
<button
className={getCss('button', theme)}
onclick={onClick}
type="submit">
{buttonContent}
</button>
)}
<TextArea
className={getCss('textarea', theme)}
dontUseTabForIndent
innerRef={innerRef}
maxLength={maxLength}
onEnter={onEnter}
onEscape={onEscape}
onInput={onInput}
onKey={onKeyDown}
selfClear
value={edited && value}
/>
<Dragzone theme={theme} right />
</div>
<Dragzone theme={theme} bottom />
</div>
);
}
}
-2
View File
@@ -4,10 +4,8 @@
"version": "1.0.0",
"dependencies": {
"common": "workspace:*",
"dompurify": "^2.3.1",
"inferno": "^7.4.8",
"tgui": "workspace:*",
"tgui-dev-server": "workspace:*",
"tgui-polyfill": "workspace:*"
}
}
+8 -25
View File
@@ -1,46 +1,29 @@
@use 'sass:color';
@use 'sass:selector';
@use './colors.scss';
.button {
align-items: center;
background-color: colors.$button;
border-radius: 0.3rem;
border: thin solid;
border-radius: 2px;
color: colors.$background;
font-family: 'Consolas', monospace;
font-weight: bold;
display: flex;
flex-grow: 1;
font-family: inherit;
font-size: 0.9rem;
height: 100%;
font-weight: bold;
justify-content: center;
padding: 0;
text-align: center;
vertical-align: middle;
width: 4rem;
width: 2.6rem;
&:hover {
background-color: lighten(colors.$button, 10%);
}
}
.button-lightMode {
@extend .button;
background-color: colors.$lightBorder;
border: none;
color: black;
&:hover {
background-color: colors.$lightHover;
}
}
/** Creates a button for each channel */
@each $channel, $color in colors.$channel-map {
.button-#{$channel} {
color: $color;
border-color: darken($color, 10%);
&:hover {
border-color: lighten($color, 10%);
color: lighten($color, 5%);
}
}
}
+17 -41
View File
@@ -1,4 +1,3 @@
@use 'sass:color';
@use 'sass:map';
$background: #131313;
@@ -7,47 +6,24 @@ $lightMode: #ffffff;
$lightBorder: #bbbbbb;
$lightHover: #eaeaea;
////////////////////////////////////////////////
// Normal chat colors
$say: #a4bad6;
$radio: #1ecc43;
$me: #5975da;
$ooc: #cca300;
////////////////////////////////////////////////
// Subchannel chat colors
$ai: #d65d95;
$admin: #ffbbff;
$binary: #1e90ff;
$centcom: #2681a5;
$command: #fcdf03;
$engi: #f37746;
$hive: #855d85;
$medical: #57b8f0;
$science: #c68cfa;
$security: #dd3535;
$syndicate: #8f4a4b;
$service: #6ca729;
$supply: #b88646;
$_channel_map: (
'say': $say,
'radio': $radio,
'me': $me,
'ooc': $ooc,
'ai': $ai,
'admin': $admin,
'binary': $binary,
'centcom': $centcom,
'command': $command,
'engi': $engi,
'hive': $hive,
'medical': $medical,
'science': $science,
'security': $security,
'syndicate': $syndicate,
'service': $service,
'supply': $supply,
'Admin': #ffbbff,
'AI': #d65d95,
'CCom': #2681a5,
'Cmd': #fcdf03,
'Engi': #f37746,
'Hive': #855d85,
'io': #1e90ff,
'Me': #5975da,
'Med': #57b8f0,
'OOC': #cca300,
'Radio': #1ecc43,
'Say': #a4bad6,
'Sci': #c68cfa,
'Sec': #dd3535,
'Supp': #b88646,
'Svc': #6ca729,
'Synd': #8f4a4b,
);
$channel_keys: map.keys($_channel_map) !default;
@@ -0,0 +1,14 @@
.center {
display: flex;
flex: 1 1 0;
height: 100%;
width: 100%;
}
.input {
display: flex;
flex: 1 1 0;
height: 100%;
width: 100%;
font-family: 'Consolas', monospace;
}
+21 -54
View File
@@ -1,72 +1,39 @@
@use 'sass:color';
@use './colors.scss';
$dragSize: 0.6rem;
$borderSize: 0.2rem;
.dragzone-horizontal {
border-left: $borderSize solid;
border-right: $borderSize solid;
color: transparent;
height: 5px;
width: 100%;
height: $dragSize;
}
.dragzone-left {
border-left: $borderSize solid;
}
.dragzone-right {
border-right: $borderSize solid;
}
.dragzone-vertical {
color: transparent;
height: 100%;
position: absolute;
top: 0;
width: 5px;
width: $dragSize;
}
@each $channel, $color in colors.$channel-map {
$darkened: darken($color, 20%);
.dragzone-top {
border-top: $borderSize solid;
}
.dragzone-bottom-#{$channel} {
@extend .dragzone-horizontal;
border-left: 2px solid $darkened;
border-right: 2px solid $darkened;
}
.dragzone-left-#{$channel} {
@extend .dragzone-vertical;
left: 0;
border-left: 2px solid $darkened;
}
.dragzone-right-#{$channel} {
@extend .dragzone-vertical;
right: 0;
border-right: 2px solid $darkened;
}
.dragzone-top-#{$channel} {
@extend .dragzone-horizontal;
border-left: 2px solid $darkened;
border-right: 2px solid $darkened;
border-top: 2px solid $darkened;
}
.dragzone-bottom {
border-bottom: $borderSize solid;
}
/** Lightmode static theme */
.dragzone-bottom-lightMode {
@extend .dragzone-horizontal;
border-left: 2px solid colors.$lightBorder;
border-right: 2px solid colors.$lightBorder;
border-bottom: 2px solid colors.$lightBorder;
}
.dragzone-left-lightMode {
@extend .dragzone-vertical;
left: 0;
border-left: 2px solid colors.$lightBorder;
}
.dragzone-right-lightMode {
@extend .dragzone-vertical;
right: 0;
border-right: 2px solid colors.$lightBorder;
}
.dragzone-top-lightMode {
@extend .dragzone-horizontal;
border-left: 2px solid colors.$lightBorder;
border-right: 2px solid colors.$lightBorder;
border-top: 2px solid colors.$lightBorder;
.dragzone-lightMode {
border-color: colors.$lightBorder;
}
+58 -1
View File
@@ -1,4 +1,6 @@
@use 'sass:meta';
@use 'sass:color';
@use './colors.scss';
// Core styles
@include meta.load-css('~tgui/styles/reset.scss');
@@ -8,6 +10,61 @@
@include meta.load-css('~tgui/styles/components/TextArea.scss');
// Local styles
@include meta.load-css('./button.scss');
@include meta.load-css('./content.scss');
@include meta.load-css('./dragzone.scss');
@include meta.load-css('./modal.scss');
@include meta.load-css('./textarea.scss');
@include meta.load-css('./window.scss');
@keyframes gradient {
0% {
background-position: 0 0;
}
100% {
background-position: 100% 0;
}
}
@each $channel, $color in colors.$channel-map {
$darkened: darken($color, 20%);
.button-#{$channel} {
border-color: darken($color, 10%);
color: $color;
&:hover {
border-color: lighten($color, 10%);
color: lighten($color, 5%);
}
}
.dragzone-#{$channel} {
border-color: $darkened;
}
.textarea-#{$channel} {
color: $color;
}
.window-#{$channel} {
&:after {
animation: gradient 10s linear infinite;
background: linear-gradient(
to right,
darken($color, 35%),
$color,
lighten($color, 10%),
$color,
darken($color, 35%)
);
background-position: 0% 0%;
background-size: 500% auto;
bottom: 0px;
content: '';
height: 2px;
left: 0px;
position: absolute;
right: 0px;
z-index: 999;
}
}
}
-71
View File
@@ -1,71 +0,0 @@
@use 'sass:color';
@use './colors.scss';
@keyframes gradient {
0% {
background-position: 0 0;
}
100% {
background-position: 100% 0;
}
}
.modal {
background-color: colors.$background;
display: flex;
flex-direction: column;
max-width: 231px;
width: 100%;
}
.modal-lightMode {
@extend .modal;
background-color: colors.$lightMode;
}
.modal__content {
display: flex;
flex-direction: row;
height: 100%;
padding: 1px 0 1px 5px;
}
/** Window sizes */
.modal-30 {
height: 30px;
}
.modal-50 {
height: 50px;
}
.modal-70 {
height: 70px;
}
/** Creates an animated border for each channel */
@each $channel, $color in colors.$channel-map {
.modal-#{$channel} {
&:after {
animation: gradient 10s linear infinite;
background: linear-gradient(
to right,
darken($color, 35%),
$color,
lighten($color, 10%),
$color,
darken($color, 35%)
);
background-position: 0% 0%;
background-size: 500% auto;
bottom: 0px;
content: '';
height: 2px;
left: 0px;
position: absolute;
right: 0px;
z-index: 999;
}
}
}
+7 -19
View File
@@ -1,23 +1,11 @@
@use './colors.scss';
.textarea {
background-color: transparent;
align-items: center;
background: transparent;
border: none;
color: colors.$background;
flex-grow: 1;
font-family: 'Lucida Console', monospace;
display: flex;
flex-grow: 4;
font-family: inherit;
font-size: 1.1rem;
margin-top: 2px;
}
.textarea-lightMode {
@extend .textarea;
color: black;
}
/** Creates an input for each channel */
@each $channel, $color in colors.$channel-map {
.textarea-#{$channel} {
color: $color;
}
overflow: hidden;
margin: 0.1rem 0 0 0.4rem;
}
+29
View File
@@ -0,0 +1,29 @@
@use 'sass:color';
@use './colors.scss';
.window {
background-color: colors.$background;
display: flex;
flex-direction: column;
max-width: 231px;
height: 100%;
width: 100%;
overflow: hidden;
}
.window-lightMode {
background-color: colors.$lightMode;
}
/** Window sizes */
.window-30 {
height: 30px;
}
.window-50 {
height: 50px;
}
.window-70 {
height: 70px;
}
+19
View File
@@ -0,0 +1,19 @@
import { debounce, throttle } from 'common/timer';
const SECONDS = 1000;
/** Timers: Prevents overloading the server, throttles messages */
export const byondMessages = {
// Debounce: Prevents spamming the server
channelIncrementMsg: debounce(
(visible: boolean) => Byond.sendMessage('thinking', visible),
0.4 * SECONDS
),
forceSayMsg: debounce(
(entry: string) => Byond.sendMessage('force', { entry, channel: 'Say' }),
1 * SECONDS,
true
),
// Throttle: Prevents spamming the server
typingMsg: throttle(() => Byond.sendMessage('typing'), 4 * SECONDS),
} as const;
-67
View File
@@ -1,67 +0,0 @@
import { RefObject } from 'inferno';
export type Modal = {
events: Events;
fields: Fields;
setState: (state: {}) => void;
state: State;
timers: Timers;
};
type Events = {
onArrowKeys: (direction: number, value: string) => void;
onBackspaceDelete: () => void;
onClick: () => void;
onEscape: () => void;
onEnter: (event: KeyboardEvent, value: string) => void;
onForce: () => void;
onKeyDown: (event: KeyboardEvent) => void;
onIncrementChannel: () => void;
onInput: (event: InputEvent, value: string) => void;
onComponentMount: () => void;
onComponentUpdate: () => void;
onRadioPrefix: () => void;
onReset: (channel?: number) => void;
onSetSize: (size: number) => void;
onViewHistory: () => void;
};
type Fields = {
historyCounter: number;
innerRef: RefObject<HTMLInputElement>;
lightMode: boolean;
maxLength: number;
radioPrefix: string;
tempHistory: string;
value: string;
};
export type State = {
buttonContent: string | number;
channel: number;
edited: boolean;
size: number;
};
type Timers = {
channelDebounce: (ModeDebounce) => void;
forceDebounce: (ForceDebounce) => void;
typingThrottle: () => void;
};
type ModeDebounce = {
mode: boolean;
};
type ForceDebounce = {
channel: number;
entry: string;
};
export type DragzoneProps = {
theme: string;
top: boolean;
right: boolean;
bottom: boolean;
left: boolean;
};
+1 -1
View File
@@ -35,7 +35,7 @@ type LobbyData = {
type MafiaData = {
players: PlayerInfo[];
lobbydata: LobbyData[];
user_notes: number;
user_notes: string;
roleinfo: RoleInfo;
phase: string;
turn: number;
-2
View File
@@ -9257,10 +9257,8 @@ resolve@^2.0.0-next.3:
resolution: "tgui-say@workspace:packages/tgui-say"
dependencies:
common: "workspace:*"
dompurify: ^2.3.1
inferno: ^7.4.8
tgui: "workspace:*"
tgui-dev-server: "workspace:*"
tgui-polyfill: "workspace:*"
languageName: unknown
linkType: soft