mirror of
https://github.com/Aurorastation/Aurora.3.git
synced 2026-08-26 22:42:26 +01:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -564,4 +564,4 @@
|
||||
|
||||
output += "</table></div>"
|
||||
|
||||
usr << browse(output,"window=lookupbans;size=900x700")
|
||||
usr << browse(HTML_SKELETON(output), "window=lookupbans;size=900x700")
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Vendored
+5
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}`,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user