516: fixes tgui-say, chat flickering and chat crashing on export (#20667)

title

chat crashing on export fixed by
https://github.com/VOREStation/VOREStation/pull/16713, chat flickering
is fixed by https://github.com/VOREStation/VOREStation/pull/16734, tgui
say is https://github.com/Monkestation/Monkestation2.0/pull/2116

also cleaned up some more html uis without skeletons 👻

---------

Co-authored-by: harryob <55142896+harryob@users.noreply.github.com>
This commit is contained in:
harry
2025-03-27 11:17:00 +00:00
committed by GitHub
co-authored by harryob
parent 37d4ebf7d6
commit bd418cfd8d
59 changed files with 1102 additions and 1052 deletions
+1 -1
View File
@@ -83,7 +83,7 @@
t += "Dispenser [(src.disabled ? "deactivated" : "activated")] - <A href='byond://?src=[REF(src)];toggleOn=1'>[(src.disabled ? "Enable" : "Disable")]?</a><br>\n"
t += "Uses Left: [uses]. <A href='byond://?src=[REF(src)];toggleUse=1'>Activate the dispenser?</A><br>\n"
user << browse(t, "window=computer;size=575x450")
user << browse(HTML_SKELETON(t), "window=computer;size=575x450")
onclose(user, "computer")
return
+1 -1
View File
@@ -216,7 +216,7 @@
t += "<small><A href='byond://?src=[REF(src)];add=1;'>(add new)</A></small><BR>"
t+= "<UL></TT>"
user << browse(t, "window=navbeacon")
user << browse(HTML_SKELETON(t), "window=navbeacon")
onclose(user, "navbeacon")
return
+1 -1
View File
@@ -438,7 +438,7 @@ GLOBAL_LIST_INIT_TYPED(allConsoles, /obj/machinery/requests_console, list())
dat += "<b>[department] Department</b><hr>"
dat += "[info]"
dat += "</center>"
usr << browse(dat, "window=Information;size=560x240")
usr << browse(HTML_SKELETON(dat), "window=Information;size=560x240")
// Toggle the paper bin lid.
if(href_list["setLid"])
@@ -28,7 +28,7 @@
if(stat & (BROKEN|NOPOWER))
return
user.set_machine(src)
var/dat = "<TITLE>Telecommunication Server Monitor</TITLE><center><b>Telecommunications Server Monitor</b></center>"
var/dat = "<center><b>Telecommunications Server Monitor</b></center>"
switch(screen)
@@ -64,7 +64,7 @@
dat += log_entries_to_text(user, SelectedServer)
user << browse(dat, "window=comm_monitor;size=575x400")
user << browse(HTML_SKELETON_TITLE("Telecommunications Server Monitor", dat), "window=comm_monitor;size=575x400")
onclose(user, "server_control")
temp = ""
@@ -98,7 +98,7 @@
//If the computer is being hacked or is emagged, display the reboot message.
if(hacking || emag)
message = rebootmsg
var/dat = "<head><title>Message Monitor Console</title></head><body>"
var/dat = "<body>"
dat += "<center><h2>Message Monitor Console</h2></center><hr>"
dat += "<center><h4><font color='blue'[message]</h5></center>"
@@ -247,7 +247,7 @@
dat += "</body>"
message = defaultmsg
user << browse(dat, "window=message;size=700x700")
user << browse(HTML_SKELETON_TITLE("Message Monitor Console", dat), "window=message;size=700x700")
onclose(user, "message")
return
@@ -24,7 +24,7 @@
if(stat & (BROKEN|NOPOWER))
return
user.set_machine(src)
var/dat = "<TITLE>Telecommunications Monitor</TITLE><center><b>Telecommunications Monitor</b></center>"
var/dat = "<center><b>Telecommunications Monitor</b></center>"
switch(screen)
@@ -59,7 +59,7 @@
user << browse(dat, "window=comm_monitor;size=575x400")
user << browse(HTML_SKELETON_TITLE("Telecommunications Monitor", dat), "window=comm_monitor;size=575x400")
onclose(user, "server_control")
temp = ""
@@ -69,7 +69,7 @@
if(stat & (BROKEN|NOPOWER))
return
user.set_machine(src)
var/dat = "<TITLE>Telecommunication Traffic Control</TITLE><center><b>Telecommunications Traffic Control</b></center>"
var/dat = "<center><b>Telecommunications Traffic Control</b></center>"
switch(screen)
@@ -105,7 +105,7 @@
dat += "<a href='byond://?src=[REF(src)];operation=togglerun'>NEVER</a>"
user << browse(dat, "window=traffic_control;size=575x400")
user << browse(HTML_SKELETON_TITLE("Telecommunication Traffic Control", dat), "window=traffic_control;size=575x400")
onclose(user, "server_control")
temp = ""
@@ -107,6 +107,6 @@ Code:
<A href='byond://?src=[REF(src)];code=1'>+</A>
<A href='byond://?src=[REF(src)];code=5'>+</A><BR>
</TT>"}
user << browse(dat, "window=radio")
user << browse(HTML_SKELETON(dat), "window=radio")
onclose(user, "radio")
return
+1 -1
View File
@@ -564,4 +564,4 @@
output += "</table></div>"
usr << browse(output,"window=lookupbans;size=900x700")
usr << browse(HTML_SKELETON(output), "window=lookupbans;size=900x700")
+1 -1
View File
@@ -309,7 +309,7 @@ GLOBAL_PROTECT(jobban_keylist)
return
var/dat = ""
var/header = "<head><title>Job-Ban Panel: [ckey]</title></head>"
var/header = ""
var/body
var/jobs = ""
+3 -6
View File
@@ -19,19 +19,16 @@
// Failed to fix, using tgalert as fallback
action = input(src, "Did that work?", "", "Yes") in list("Yes", "No, switch to old ui")
if (action == "No, switch to old ui")
winset(src, "output", "on-show=&is-disabled=0&is-visible=1")
winset(src, "browseroutput", "is-disabled=1;is-visible=0")
winset(src, "legacy_output_selector", "left=output_legacy")
log_tgui(src, "Failed to fix.", context = "verb/fix_tgui_panel")
/client/proc/nuke_chat()
// Catch all solution (kick the whole thing in the pants)
winset(src, "output", "on-show=&is-disabled=0&is-visible=1")
winset(src, "browseroutput", "is-disabled=1;is-visible=0")
winset(src, "legacy_output_selector", "left=output_legacy")
if(!tgui_panel || !istype(tgui_panel))
log_tgui(src, "tgui_panel datum is missing",
context = "verb/fix_tgui_panel")
tgui_panel = new(src)
tgui_panel.initialize(force = TRUE)
// Force show the panel to see if there are any errors
winset(src, "output", "is-disabled=1&is-visible=0")
winset(src, "browseroutput", "is-disabled=0;is-visible=1")
winset(src, "legacy_output_selector", "left=output_browser")
+8
View File
@@ -0,0 +1,8 @@
author: harry, shadowlarkens, Absolucy
delete-after: True
changes:
- bugfix: "tgui-say now opens properly again."
- bugfix: "the chat no longer flickers weirdly."
- bugfix: "the chat now exports properly."
+33 -3
View File
@@ -1130,14 +1130,26 @@ window "outputwindow"
command = ".winset \"mebutton.is-checked=true ? input.command=\"!me \\\"\" : input.command=\"\"mebutton.is-checked=true ? saybutton.is-checked=false\"\"mebutton.is-checked=true ? oocbutton.is-checked=false\""
is-flat = true
button-type = pushbox
elem "browseroutput"
type = BROWSER
elem "legacy_output_selector"
type = CHILD
pos = 0,0
size = 640x456
anchor1 = 0,0
anchor2 = 100,100
saved-params = "splitter"
left = "output_legacy"
is-vert = false
window "output_legacy"
elem "output_legacy"
type = MAIN
pos = 0,0
size = 640x456
anchor1 = -1,-1
anchor2 = -1,-1
background-color = none
saved-params = ""
saved-params = "pos;size;is-minimized;is-maximized"
is-pane = true
elem "output"
type = OUTPUT
pos = 0,0
@@ -1145,6 +1157,24 @@ window "outputwindow"
anchor1 = 0,0
anchor2 = 100,100
is-default = true
window "output_browser"
elem "output_browser"
type = MAIN
pos = 0,0
size = 640x456
anchor1 = -1,-1
anchor2 = -1,-1
background-color = none
saved-params = "pos;size;is-minimized;is-maximized"
is-pane = true
elem "browseroutput"
type = BROWSER
pos = 0,0
size = 640x456
anchor1 = 0,0
anchor2 = 100,100
background-color = none
saved-params = ""
window "preferences_window"
+1 -1
View File
@@ -337,7 +337,7 @@ rules:
## Require or disallow named function expressions
# func-names: error
## Enforce the consistent use of either function declarations or expressions
func-style: [error, expression]
# func-style: [error, expression]
## Enforce line breaks between arguments of a function call
# function-call-argument-newline: error
## Enforce consistent line breaks inside function parentheses
+5
View File
@@ -162,6 +162,11 @@ type ByondType = {
*/
parseJson(text: string): any;
/**
* Downloads a blob, platform-agnostic
*/
saveBlob(blob: Blob, filename: string, ext: string): void;
/**
* Sends a message to `/datum/tgui_window` which hosts this window instance.
*/
+139
View File
@@ -0,0 +1,139 @@
/**
* ### 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.
* Don't access Esc or Escape directly, use isEscape() instead
*
* 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 {
A = 'a',
Alt = 'Alt',
Backspace = 'Backspace',
Control = 'Control',
D = 'd',
Delete = 'Delete',
Down = 'ArrowDown',
E = 'e',
End = 'End',
Enter = 'Enter',
Esc = 'Esc',
Escape = 'Escape',
Home = 'Home',
Insert = 'Insert',
Left = 'ArrowLeft',
N = 'n',
PageDown = 'PageDown',
PageUp = 'PageUp',
Right = 'ArrowRight',
S = 's',
Shift = 'Shift',
Space = ' ',
Tab = 'Tab',
Up = 'ArrowUp',
W = 'w',
Z = 'z',
}
/**
* ### isEscape
*
* Checks if the user has hit the 'ESC' key on their keyboard.
* There's a weirdness in BYOND where this could be either the string
* 'Escape' or 'Esc' depending on the browser. This function handles
* both cases.
*
* @param key - the key to check, typically from event.key
* @returns true if key is Escape or Esc, false otherwise
*/
export function isEscape(key: string): boolean {
return key === KEY.Esc || key === KEY.Escape;
}
/**
* ### isAlphabetic
*
* Checks if the user has hit any alphabetic key (a - z)
*
* @param key - the key to check, typically from event.key
* @returns true if key is in the range of a-z
*/
export function isAlphabetic(key: string): boolean {
return key >= KEY.A && key <= KEY.Z;
}
/**
* ### isNumeric
*
* Checks if the user has hit any numeric key (0 - 9)
*
* @param key - the key to check, typically from event.key
* @returns true if key is in the range of 0 - 9
*/
export function isNumeric(key: string): boolean {
return key >= '0' && key <= '9';
}
/**
* ### isCardinal
*
* Checks if the user has hit any cardinal key (n s w e)
*
* @param key - the key to check, typically from event.key
* @returns true if key matches any cardinal n s w e
*/
export function isCardinal(key: string): boolean {
return key === KEY.N || key === KEY.S || key === KEY.W || key === KEY.E;
}
/**
* ### isArrow
*
* Checks if the user has hit any arrow key
*
* @param key - the key to check, typically from event.key
* @returns true if key matches any arrow keys
*/
export function isArrow(key: string): boolean {
return (
key === KEY.Up || key === KEY.Down || key === KEY.Left || key === KEY.Right
);
}
/**
* ### isWasd
*
* Checks if the user has hit any w a s d key
*
* @param key - the key to check, typically from event.key
* @returns true if key matches any w a s d
*/
export function isWasd(key: string): boolean {
return key === KEY.W || key === KEY.A || key === KEY.S || key === KEY.D;
}
/**
* ### isMovement
*
* Checks if the user has hit any movement key (w a s d and arrow keys)
*
* @param key - the key to check, typically from event.key
* @returns true if key matches any movement key w a s d and arrow keys
*/
export function isMovement(key: string): boolean {
return isWasd(key) || isArrow(key);
}
+2 -2
View File
@@ -21,7 +21,7 @@ const saveChatToStorage = async (store) => {
const settings = selectSettings(store.getState());
const state = selectChat(store.getState());
if (!window.hubStorage) {
if (!window.hubStorage && !Byond.TRIDENT) {
const indexedDbBackend = await storage.backendPromise;
indexedDbBackend.processChatMessages(chatRenderer.storeQueue);
} else {
@@ -44,7 +44,7 @@ const loadChatFromStorage = async (store) => {
const state = await storage.get('chat-state');
let messages;
if (!window.hubStorage) {
if (!window.hubStorage && !Byond.TRIDENT) {
messages = await (await storage.backendPromise).getChatMessages();
} else {
messages = await storage.get('chat-messages');
+2 -2
View File
@@ -579,13 +579,13 @@ class ChatRenderer {
+ '</body>\n'
+ '</html>\n';
// Create and send a nice blob
const blob = new Blob([pageHtml]);
const blob = new Blob([pageHtml], { type: 'text/plain' });
const timestamp = new Date()
.toISOString()
.substring(0, 19)
.replace(/[-:]/g, '')
.replace('T', '-');
window.navigator.msSaveBlob(blob, `ss13-chatlog-${timestamp}.html`);
Byond.saveBlob(blob, `ss13-chatlog-${timestamp}.html`, '.html');
}
clear() {
+2 -8
View File
@@ -75,14 +75,8 @@ const setupApp = () => {
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
// Unhide the panel
Byond.winset('output', {
'is-visible': false,
});
Byond.winset('browseroutput', {
'is-visible': true,
'is-disabled': false,
'pos': '0x0',
'size': '0x0',
Byond.winset('legacy_output_selector', {
left: 'output_browser',
});
// Resize the panel to match the non-browser output
@@ -0,0 +1,58 @@
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('LOOC');
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 return false when current channel is not visible', () => {
channelIterator.set('LOOC');
expect(channelIterator.isVisible()).toBe(false);
});
it('should not leak a message from a blacklisted channel', () => {
channelIterator.set('Mentor');
expect(channelIterator.next()).toBe('Mentor');
});
it('should not leak a message from a blacklisted channel', () => {
channelIterator.set('Admin');
expect(channelIterator.next()).toBe('Admin');
});
});
+65
View File
@@ -0,0 +1,65 @@
export type Channel =
| 'Say'
| 'Radio'
| 'Me'
| 'OOC'
| 'LOOC'
| 'Mentor'
| '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',
'LOOC',
'Mentor',
'Admin',
];
private readonly blacklist: Channel[] = ['Mentor', 'Admin'];
private readonly quiet: Channel[] = ['OOC', 'LOOC', 'Mentor', '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;
}
}
+365
View File
@@ -0,0 +1,365 @@
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, windowClose, windowSet } from './helpers';
import { BooleanLike } from 'common/react';
import { isEscape, 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/;
const ROWS: Record<keyof typeof WINDOW_SIZES, number> = {
small: 1,
medium: 2,
large: 3,
width: 1, // not used
} as const;
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);
}
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()
?.replace('.', ':') 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;
default:
if (isEscape(event.key)) {
this.handleClose();
}
}
}
handleOpen = (data: ByondOpen) => {
setTimeout(() => {
this.innerRef.current?.focus();
}, 0);
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() });
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
autoCorrect="off"
className={`textarea textarea-${theme}`}
maxLength={this.maxLength}
onInput={this.handleInput}
onKeyDown={this.handleKeyDown}
ref={this.innerRef}
spellCheck={false}
rows={ROWS[this.state.size] || 1}
/>
</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}
/>
);
};
+37
View File
@@ -0,0 +1,37 @@
/** Window sizes in pixels */
export enum WINDOW_SIZES {
small = 30,
medium = 50,
large = 70,
width = 380,
}
/** Line lengths for autoexpand */
export enum LINE_LENGTHS {
small = 38,
medium = 76,
}
/**
* Radio prefixes.
* Displays the name in the left button, tags a css class.
*/
export const RADIO_PREFIXES = {
'; ': 'Com',
':b ': 'io',
':c ': 'Cmd',
':e ': 'Engi',
':g ': 'Cling',
':m ': 'Med',
':n ': 'Sci',
':o ': 'AI',
':z ': 'Ent',
':s ': 'Sec',
':x ': 'Synd',
':u ': 'Supp',
':v ': 'Svc',
':y ': 'Hail',
':f ': 'Uncom',
':q ': 'Pen',
':t ': 'Merc',
} as const;
@@ -1,93 +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 = 380,
}
/** Line lengths for autoexpand */
export enum LINE_LENGTHS {
small = 38,
medium = 76,
}
/**
* Radio prefixes.
* Contains the properties:
* id - string. css class identifier.
* label - string. button label.
*/
export const RADIO_PREFIXES = {
'; ': {
id: 'radio',
label: 'Com',
},
':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',
},
':x ': {
id: 'syndicate',
label: 'Syndi',
},
':b': {
id: 'syndicate',
label: 'Burg',
},
':q ': {
id: 'syndicate',
label: 'Ninja',
},
':t': {
id: 'syndicate',
label: 'Merc',
},
':q': {
id: 'syndicate',
label: 'Pen',
},
':y ': {
id: 'hail',
label: 'Hail',
},
':z ': {
id: 'ent',
label: 'Ent',
},
':u ': {
id: 'supply',
label: 'Ops',
},
':v ': {
id: 'service',
label: 'Svc',
},
} as const;
@@ -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);
};
+46
View File
@@ -0,0 +1,46 @@
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) => {
setWindowVisibility(true);
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 = () => {
setWindowVisibility(false);
Byond.winset('map', {
focus: true,
});
Byond.sendMessage('close');
};
/**
* Modifies the window size.
*/
export const windowSet = (size = WINDOW_SIZES.small) => {
let sizeStr = `${WINDOW_SIZES.width}x${size}`;
Byond.winset('tgui_say.browser', {
size: sizeStr,
});
Byond.winset('tgui_say', {
size: sizeStr,
});
};
/** Helper function to set window size and visibility */
const setWindowVisibility = (visible: boolean) => {
Byond.winset('tgui_say', {
'is-visible': visible,
size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
});
};
-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: '700,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;
+6 -14
View File
@@ -1,19 +1,11 @@
import './styles/main.scss';
import { createRenderer } from 'tgui/renderer';
import { TguiSay } from './interfaces/TguiSay';
import { render } from 'inferno';
const renderApp = createRenderer(() => {
return <TguiSay />;
});
import { TguiSay } from './TguiSay';
const setupApp = () => {
// Delay setup
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupApp);
return;
}
document.onreadystatechange = function () {
if (document.readyState !== 'complete') return;
renderApp();
const root = document.getElementById('react-root');
render(<TguiSay />, root);
};
setupApp();
@@ -1,89 +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
key="options"
className={getCss('button', theme)}
onclick={onClick}
type="submit">
{buttonContent}
</button>
)}
<TextArea
key="type"
className={getCss('textarea', theme)}
dontUseTabForIndent
innerRef={innerRef}
maxLength={maxLength}
onEnter={onEnter}
onEscape={onEscape}
onInput={onInput}
onKey={onKeyDown}
selfClear
value={edited && value}
/>
{!!theme && (
<button
key="escape"
className={getCss('button', theme)}
onclick={onEscape}
type="submit"
style={{ 'width': '2rem', 'margin-right': '5px' }}>
X
</button>
)}
<Dragzone theme={theme} right />
</div>
<Dragzone theme={theme} bottom />
</div>
);
}
}
+1 -3
View File
@@ -4,10 +4,8 @@
"version": "1.0.0",
"dependencies": {
"common": "workspace:*",
"dompurify": "^2.5.4",
"inferno": "^7.4.8",
"inferno": "^8.2.1",
"tgui": "workspace:*",
"tgui-dev-server": "workspace:*",
"tgui-polyfill": "workspace:*"
}
}
+15 -25
View File
@@ -1,46 +1,36 @@
@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: 6rem;
width: 2.6rem;
&:hover {
background-color: lighten(colors.$button, 10%);
}
}
// Remove conditionals with 516
@supports (not (-webkit-hyphens: none)) and (not (-moz-appearance: none)) {
.button {
outline: none;
}
}
.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 -20
View File
@@ -1,4 +1,3 @@
@use 'sass:color';
@use 'sass:map';
$background: #131313;
@@ -6,6 +5,7 @@ $button: #1f1f1f;
$lightMode: #ffffff;
$lightBorder: #bbbbbb;
$lightHover: #eaeaea;
$scrollbar-color-multiplier: 1 !default;
////////////////////////////////////////////////
// Normal chat colors
@@ -33,25 +33,22 @@ $hail: #8b4cd8;
$ent: #cfcfcf;
$_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,
'hail': $hail,
'ent': $ent,
'Say': $say,
'Comm': $radio,
'Me': $me,
'OOC': $ooc,
'AI': $ai,
'io': $binary,
'Cmd': $command,
'Engi': $engi,
'Med': $medical,
'Sci': $science,
'Sec': $security,
'Synd': $syndicate,
'Svc': $service,
'Supp': $supply,
'Hail': $hail,
'Ent': $ent,
);
$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: 380px;
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 Sans Unicode', 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: 380px;
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;
};
+26 -3
View File
@@ -4011,6 +4011,13 @@ __metadata:
languageName: node
linkType: hard
"csstype@npm:^3.1.2":
version: 3.1.3
resolution: "csstype@npm:3.1.3"
checksum: 8db785cc92d259102725b3c694ec0c823f5619a84741b5c7991b8ad135dfaa66093038a1cc63e03361a6cd28d122be48f2106ae72334e067dd619a51f49eddf7
languageName: node
linkType: hard
"cubic2quad@npm:^1.0.0":
version: 1.1.1
resolution: "cubic2quad@npm:1.1.1"
@@ -5799,6 +5806,13 @@ __metadata:
languageName: node
linkType: hard
"inferno-vnode-flags@npm:8.2.3":
version: 8.2.3
resolution: "inferno-vnode-flags@npm:8.2.3"
checksum: 4618a7598b5471ec71bb4972a8ff57dd6718d55624d9c1ae6336416f8d3c47002b2d8b088b0e976ef07bd239a057f87f16ad1238ae7dafd1c5c4d6644f1dabbc
languageName: node
linkType: hard
"inferno@npm:^7.4.8":
version: 7.4.8
resolution: "inferno@npm:7.4.8"
@@ -5810,6 +5824,17 @@ __metadata:
languageName: node
linkType: hard
"inferno@npm:^8.2.1":
version: 8.2.3
resolution: "inferno@npm:8.2.3"
dependencies:
csstype: ^3.1.2
inferno-vnode-flags: 8.2.3
opencollective-postinstall: ^2.0.3
checksum: 630ee33ad8e12d62ef2ad82ea5b8ba2dd426326fb5aa077f892051b59ccdbe0a276637c777544b01367a74eba20a5122ce78d885dbf9650dfc59f419bf57959b
languageName: node
linkType: hard
"inflight@npm:^1.0.4":
version: 1.0.6
resolution: "inflight@npm:1.0.6"
@@ -9826,10 +9851,8 @@ resolve@^2.0.0-next.3:
resolution: "tgui-say@workspace:packages/tgui-say"
dependencies:
common: "workspace:*"
dompurify: ^2.5.4
inferno: ^7.4.8
inferno: ^8.2.1
tgui: "workspace:*"
tgui-dev-server: "workspace:*"
tgui-polyfill: "workspace:*"
languageName: unknown
linkType: soft