[MIRROR] Moves UIs to TGUI core (#9967)

Co-authored-by: Kashargul <144968721+Kashargul@users.noreply.github.com>
This commit is contained in:
CHOMPStation2StaffMirrorBot
2025-01-29 01:34:31 +01:00
committed by GitHub
co-authored by Kashargul
parent a0cc8402d7
commit 3aa9314ff4
751 changed files with 20178 additions and 15992 deletions
@@ -270,7 +270,7 @@
return 1
if("tail3_color")
if(can_change(owner, APPEARANCE_HAIR_COLOR))
var/new_hair = tgui_color_picker(ui.user, "Please select secondary tail color.", "3rd Tail Color", rgb(owner.r_tail3, owner.g_tail3, owner.b_tail3))
var/new_hair = tgui_color_picker(ui.user, "Please select tertiary tail color.", "3rd Tail Color", rgb(owner.r_tail3, owner.g_tail3, owner.b_tail3))
if(new_hair && can_still_topic(owner, state))
owner.r_tail3 = hex2num(copytext(new_hair, 2, 4))
owner.g_tail3 = hex2num(copytext(new_hair, 4, 6))
@@ -315,7 +315,7 @@
return 1
if("wing3_color")
if(can_change(owner, APPEARANCE_HAIR_COLOR))
var/new_hair = tgui_color_picker(ui.user, "Please select secondary wing color.", "3rd Wing Color", rgb(owner.r_wing3, owner.g_wing3, owner.b_wing3))
var/new_hair = tgui_color_picker(ui.user, "Please select tertiary wing color.", "3rd Wing Color", rgb(owner.r_wing3, owner.g_wing3, owner.b_wing3))
if(new_hair && can_still_topic(owner, state))
owner.r_wing3 = hex2num(copytext(new_hair, 2, 4))
owner.g_wing3 = hex2num(copytext(new_hair, 4, 6))
+19 -15
View File
@@ -49,6 +49,8 @@
var/autofocus
/// Boolean field describing if the tgui_color_picker was closed by the user.
var/closed
/// The user's presets
var/preset_colors
/datum/tgui_color_picker/New(mob/user, message, title, default, timeout, autofocus)
src.autofocus = autofocus
@@ -59,6 +61,8 @@
src.timeout = timeout
start_time = world.time
QDEL_IN(src, timeout)
if(user)
src.preset_colors = user.read_preference(/datum/preference/text/preset_colors)
/datum/tgui_color_picker/Destroy(force)
SStgui.close_uis(src)
@@ -80,6 +84,8 @@
ui.set_autoupdate(timeout > 0)
/datum/tgui_color_picker/tgui_close(mob/user)
if(user)
user.write_preference_directly(/datum/preference/text/preset_colors, preset_colors)
. = ..()
closed = TRUE
@@ -99,7 +105,7 @@
. = list()
if(timeout)
.["timeout"] = CLAMP01((timeout - (world.time - start_time) - 1 SECONDS) / (timeout - 1 SECONDS))
.["presets"] = user.read_preference(/datum/preference/text/preset_colors)
.["presets"] = preset_colors
/datum/tgui_color_picker/tgui_act(action, list/params, datum/tgui/ui)
. = ..()
@@ -124,20 +130,18 @@
SStgui.close_uis(src)
return TRUE
if("preset")
if(ui.user)
var/raw_data = lowertext(params["color"])
var/index = lowertext(params["index"])
var/colors = ui.user.read_preference(/datum/preference/text/preset_colors)
var/list/entries = splittext(colors, ";")
while(LAZYLEN(entries) < 20)
entries += "#FFFFFF"
if(LAZYLEN(entries) > 20)
entries.Cut(21)
var/hex = sanitize_hexcolor(raw_data)
if (!hex || !isnum(index) || entries[index] == hex)
return
entries[index] = hex
ui.user.write_preference_directly(/datum/preference/text/preset_colors, entries.Join(";"))
var/raw_data = lowertext(params["color"])
var/index = lowertext(params["index"])
var/list/entries = splittext(preset_colors, ";")
while(LAZYLEN(entries) < 20)
entries += "#FFFFFF"
if(LAZYLEN(entries) > 20)
entries.Cut(21)
var/hex = sanitize_hexcolor(raw_data)
if (!hex || !isnum(index) || entries[index] == hex)
return
entries[index] = hex
preset_colors = entries.Join(";")
return TRUE
/datum/tgui_color_picker/proc/set_choice(choice)
+2 -2
View File
@@ -14,12 +14,12 @@ export MACRO_COUNT=8
export RUST_G_VERSION=3.5.1
#node version
export NODE_VERSION_LTS=20.13.0
export NODE_VERSION_LTS=22.11.0
# compatiblility mode MUST work with windows 7
export NODE_VERSION_COMPAT=20.2.0
# SpacemanDMM git tag
export SPACEMAN_DMM_VERSION=suite-1.8
export SPACEMAN_DMM_VERSION=suite-1.9
# Python version for mapmerge and other tools
export PYTHON_VERSION=3.11.9
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "typescript",
"version": "5.4.3-sdk",
"version": "5.6.3-sdk",
"main": "./lib/typescript.js",
"type": "commonjs",
"bin": {
+1 -1
View File
@@ -14,4 +14,4 @@ pnpEnableEsmLoader: false
preferInteractive: true
yarnPath: .yarn/releases/yarn-4.1.1.cjs
yarnPath: .yarn/releases/yarn-4.5.1.cjs
+27 -3
View File
@@ -41,6 +41,21 @@ type ByondType = {
*/
windowId: string;
/**
* True if javascript is running in BYOND.
*/
IS_BYOND: boolean;
/**
* Version of Trident engine of Internet Explorer. Null if N/A.
*/
TRIDENT: number | null;
/**
* Version of Blink engine of WebView2. Null if N/A.
*/
BLINK: number | null;
/**
* If `true`, unhandled errors and common mistakes result in a blue screen
* of death, which stops this window from handling incoming messages and
@@ -85,14 +100,14 @@ type ByondType = {
*
* Returns a promise with a key-value object containing all properties.
*/
winget(id: string | null): Promise<object>;
winget(id: string | null): Promise<Record<string, any>>;
/**
* Retrieves all properties of the BYOND skin element.
*
* Returns a promise with a key-value object containing all properties.
*/
winget(id: string | null, propName: '*'): Promise<object>;
winget(id: string | null, propName: '*'): Promise<Record<string, any>>;
/**
* Retrieves an exactly one property of the BYOND skin element,
@@ -108,7 +123,7 @@ type ByondType = {
*
* Returns a promise with a key-value object containing listed properties.
*/
winget(id: string | null, propNames: string[]): Promise<object>;
winget(id: string | null, propNames: string[]): Promise<Record<string, any>>;
/**
* Assigns properties to BYOND skin elements in bulk.
@@ -180,4 +195,13 @@ interface Window {
Byond: ByondType;
__store__: Store<unknown, AnyAction>;
__augmentStack__: (store: Store) => StackAugmentor;
// IE IndexedDB stuff.
msIndexedDB: IDBFactory;
msIDBTransaction: IDBTransaction;
// 516 byondstorage API.
hubStorage: Storage;
domainStorage: Storage;
serverStorage: Storage;
}
+22 -21
View File
@@ -1,15 +1,15 @@
{
"private": true,
"name": "tgui-workspace",
"version": "5.0.1",
"packageManager": "yarn@4.1.1",
"version": "5.0.3",
"packageManager": "yarn@4.5.1",
"workspaces": [
"packages/*"
],
"scripts": {
"tgui:analyze": "webpack --analyze",
"tgui:bench": "webpack --env TGUI_BENCH=1 && node packages/tgui-bench/index.js",
"tgui:build": "webpack && webpack --config ./webpack.config.edge.js",
"tgui:build": "BROWSERSLIST_IGNORE_OLD_DATA=true webpack && webpack --config ./webpack.config.edge.js",
"tgui:dev": "node --experimental-modules packages/tgui-dev-server/index.js",
"tgui:lint": "eslint packages --ext .js,.cjs,.ts,.tsx",
"tgui:prettier": "prettier --check .",
@@ -19,18 +19,18 @@
"tgui:test-ci": "CI=true jest --color --collect-coverage",
"tgui:tsc": "tsc",
"tgui:prettier-fix": "prettier --write .",
"tgui:eslint-fix": "eslint --fix packages --ext .js,.cjs,.ts,.tsx"
"tgui:eslint-fix": "eslint --fix packages --ext .js,.cjs,.ts,.jsx,.tsx"
},
"dependencies": {
"@swc/core": "^1.4.11",
"@swc/jest": "^0.2.36",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.3",
"@types/webpack-env": "^1.18.4",
"@typescript-eslint/parser": "^7.5.0",
"@typescript-eslint/utils": "^7.5.0",
"css-loader": "^6.10.0",
"esbuild-loader": "^4.1.0",
"@swc/core": "^1.9.1",
"@swc/jest": "^0.2.37",
"@types/jest": "^29.5.14",
"@types/node": "^22.9.0",
"@types/webpack-env": "^1.18.5",
"@typescript-eslint/parser": "^8.13.0",
"@typescript-eslint/utils": "^8.13.0",
"css-loader": "^7.1.2",
"esbuild-loader": "^4.2.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react": "^7.34.1",
@@ -41,17 +41,18 @@
"jest": "^29.7.0",
"jest-circus": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^22.1.0",
"mini-css-extract-plugin": "^2.8.1",
"jsdom": "^25.0.1",
"mini-css-extract-plugin": "^2.9.2",
"prettier": "^3.2.5",
"sass": "^1.72.0",
"sass-loader": "^14.1.1",
"style-loader": "^3.3.4",
"sass": "^1.80.6",
"sass-loader": "^16.0.3",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.6",
"typescript": "^5.4.3",
"tgui-core": "^1.7.5",
"typescript": "5.6.3",
"url-loader": "^4.1.1",
"webpack": "^5.94.0",
"webpack-bundle-analyzer": "^4.10.1",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4"
}
}
-20
View File
@@ -1,20 +0,0 @@
import { range, zip } from './collections';
// Type assertions, these will lint if the types are wrong.
const _zip1: [string, number] = zip(['a'], [1])[0];
describe('range', () => {
test('range(0, 5)', () => {
expect(range(0, 5)).toEqual([0, 1, 2, 3, 4]);
});
});
describe('zip', () => {
test("zip(['a', 'b', 'c'], [1, 2, 3, 4])", () => {
expect(zip(['a', 'b', 'c'], [1, 2, 3, 4])).toEqual([
['a', 1],
['b', 2],
['c', 3],
]);
});
});
-94
View File
@@ -1,94 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
const EPSILON = 0.0001;
export class Color {
r: number;
g: number;
b: number;
a: number;
constructor(r = 0, g = 0, b = 0, a = 1) {
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
toString(): string {
// Alpha component needs to permit fractional values, so cannot use |
let alpha = this.a;
if (typeof alpha === 'string') {
alpha = parseFloat(this.a as any);
}
if (isNaN(alpha)) {
alpha = 1;
}
return `rgba(${this.r | 0}, ${this.g | 0}, ${this.b | 0}, ${alpha})`;
}
/** Darkens a color by a given percent. Returns a color, which can have toString called to get it's rgba() css value. */
darken(percent: number): Color {
percent /= 100;
return new Color(
this.r - this.r * percent,
this.g - this.g * percent,
this.b - this.b * percent,
this.a,
);
}
/** Brightens a color by a given percent. Returns a color, which can have toString called to get it's rgba() css value. */
lighten(percent: number): Color {
// No point in rewriting code we already have.
return this.darken(-percent);
}
/**
* Creates a color from the CSS hex color notation.
*/
static fromHex(hex: string): Color {
return new Color(
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
);
}
/**
* Linear interpolation of two colors.
*/
static lerp(c1: Color, c2: Color, n: number): Color {
return new Color(
(c2.r - c1.r) * n + c1.r,
(c2.g - c1.g) * n + c1.g,
(c2.b - c1.b) * n + c1.b,
(c2.a - c1.a) * n + c1.a,
);
}
/**
* Loops up the color in the provided list of colors
* with linear interpolation.
*/
static lookup(value: number, colors: Color[]): Color {
const len = colors.length;
if (len < 2) {
throw new Error('Needs at least two colors!');
}
const scaled = value * (len - 1);
if (value < EPSILON) {
return colors[0];
}
if (value >= 1 - EPSILON) {
return colors[len - 1];
}
const ratio = scaled % 1;
const index = scaled | 0;
return this.lerp(colors[index], colors[index + 1], ratio);
}
}
-45
View File
@@ -1,45 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
type Fn = (...args: any[]) => void;
export class EventEmitter {
private listeners: Record<string, Fn[]>;
constructor() {
this.listeners = {};
}
on(name: string, listener: Fn): void {
this.listeners[name] = this.listeners[name] || [];
this.listeners[name].push(listener);
}
off(name: string, listener: Fn): void {
const listeners = this.listeners[name];
if (!listeners) {
throw new Error(`There is no listeners for "${name}"`);
}
this.listeners[name] = listeners.filter((existingListener) => {
return existingListener !== listener;
});
}
emit(name: string, ...params: any[]): void {
const listeners = this.listeners[name];
if (!listeners) {
return;
}
for (let i = 0, len = listeners.length; i < len; i += 1) {
const listener = listeners[i];
listener(...params);
}
}
clear(): void {
this.listeners = {};
}
}
-19
View File
@@ -1,19 +0,0 @@
/**
* Throws an error such that a non-exhaustive check will error at compile time
* when using TypeScript, rather than at runtime.
*
* For example:
* enum Color { Red, Green, Blue }
* switch (color) {
* case Color.Red:
* return "red";
* case Color.Green:
* return "green";
* default:
* // This will error at compile time that we forgot blue.
* exhaustiveCheck(color);
* }
*/
export const exhaustiveCheck = (input: never) => {
throw new Error(`Unhandled case: ${input}`);
};
-23
View File
@@ -1,23 +0,0 @@
import { flow } from './fp';
describe('flow', () => {
it('composes multiple functions into one', () => {
const add2 = (x) => x + 2;
const multiplyBy3 = (x) => x * 3;
const subtract5 = (x) => x - 5;
const composedFunction = flow(add2, multiplyBy3, subtract5);
expect(composedFunction(4)).toBe(13); // ((4 + 2) * 3) - 5 = 13
});
it('handles arrays of functions', () => {
const add2 = (x) => x + 2;
const multiplyBy3 = (x) => x * 3;
const subtract5 = (x) => x - 5;
const composedFunction = flow([add2, multiplyBy3], subtract5);
expect(composedFunction(4)).toBe(13); // ((4 + 2) * 3) - 5 = 13
});
});
-38
View File
@@ -1,38 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
type Func = (...args: any[]) => any;
/**
* Creates a function that returns the result of invoking the given
* functions, where each successive invocation is supplied the return
* value of the previous.
*
* @example
* ```tsx
* const add2 = (x) => x + 2;
* const multiplyBy3 = (x) => x * 3;
* const subtract5 = (x) => x - 5;
*
* const composedFunction = flow(add2, multiplyBy3, subtract5); // ((4 + 2) * 3) - 5 = 13
* const composedFunction2 = flow([add2, multiplyBy3], subtract5); // ((4 + 2) * 3) - 5 = 13
*
*/
export const flow =
(...funcs: Array<Func | Func[]>) =>
(input: any, ...rest: any[]): any => {
let output = input;
for (let func of funcs) {
// Recurse into the array of functions
if (Array.isArray(func)) {
output = flow(...func)(output, ...rest);
} else if (func) {
output = func(output, ...rest);
}
}
return output;
};
-86
View File
@@ -1,86 +0,0 @@
/**
* All possible browser keycodes, in one file.
*
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
export const KEY_BACKSPACE = 8;
export const KEY_TAB = 9;
export const KEY_ENTER = 13;
export const KEY_SHIFT = 16;
export const KEY_CTRL = 17;
export const KEY_ALT = 18;
export const KEY_PAUSE = 19;
export const KEY_CAPSLOCK = 20;
export const KEY_ESCAPE = 27;
export const KEY_SPACE = 32;
export const KEY_PAGEUP = 33;
export const KEY_PAGEDOWN = 34;
export const KEY_END = 35;
export const KEY_HOME = 36;
export const KEY_LEFT = 37;
export const KEY_UP = 38;
export const KEY_RIGHT = 39;
export const KEY_DOWN = 40;
export const KEY_INSERT = 45;
export const KEY_DELETE = 46;
export const KEY_0 = 48;
export const KEY_1 = 49;
export const KEY_2 = 50;
export const KEY_3 = 51;
export const KEY_4 = 52;
export const KEY_5 = 53;
export const KEY_6 = 54;
export const KEY_7 = 55;
export const KEY_8 = 56;
export const KEY_9 = 57;
export const KEY_A = 65;
export const KEY_B = 66;
export const KEY_C = 67;
export const KEY_D = 68;
export const KEY_E = 69;
export const KEY_F = 70;
export const KEY_G = 71;
export const KEY_H = 72;
export const KEY_I = 73;
export const KEY_J = 74;
export const KEY_K = 75;
export const KEY_L = 76;
export const KEY_M = 77;
export const KEY_N = 78;
export const KEY_O = 79;
export const KEY_P = 80;
export const KEY_Q = 81;
export const KEY_R = 82;
export const KEY_S = 83;
export const KEY_T = 84;
export const KEY_U = 85;
export const KEY_V = 86;
export const KEY_W = 87;
export const KEY_X = 88;
export const KEY_Y = 89;
export const KEY_Z = 90;
export const KEY_F1 = 112;
export const KEY_F2 = 113;
export const KEY_F3 = 114;
export const KEY_F4 = 115;
export const KEY_F5 = 116;
export const KEY_F6 = 117;
export const KEY_F7 = 118;
export const KEY_F8 = 119;
export const KEY_F9 = 120;
export const KEY_F10 = 121;
export const KEY_F11 = 122;
export const KEY_F12 = 123;
export const KEY_SEMICOLON = 186;
export const KEY_EQUAL = 187;
export const KEY_COMMA = 188;
export const KEY_MINUS = 189;
export const KEY_PERIOD = 190;
export const KEY_SLASH = 191;
export const KEY_LEFT_BRACKET = 219;
export const KEY_BACKSLASH = 220;
export const KEY_RIGHT_BRACKET = 221;
export const KEY_QUOTE = 222;
-58
View File
@@ -1,58 +0,0 @@
/**
* ### 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 {
Alt = 'Alt',
Backspace = 'Backspace',
Control = 'Control',
Delete = 'Delete',
Down = 'ArrowDown',
End = 'End',
Enter = 'Enter',
Esc = 'Esc',
Escape = 'Escape',
Home = 'Home',
Insert = 'Insert',
Left = 'ArrowLeft',
PageDown = 'PageDown',
PageUp = 'PageUp',
Right = 'ArrowRight',
Shift = 'Shift',
Space = ' ',
Tab = 'Tab',
Up = 'ArrowUp',
}
/**
* ### 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;
}
-98
View File
@@ -1,98 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Limits a number to the range between 'min' and 'max'.
*/
export const clamp = (value, min, max) => {
return value < min ? min : value > max ? max : value;
};
/**
* Limits a number between 0 and 1.
*/
export const clamp01 = (value) => {
return value < 0 ? 0 : value > 1 ? 1 : value;
};
/**
* Scales a number to fit into the range between min and max.
*/
export const scale = (value, min, max) => {
return (value - min) / (max - min);
};
/**
* Robust number rounding.
*
* Adapted from Locutus, see: http://locutus.io/php/math/round/
*
* @param {number} value
* @param {number} precision
* @return {number}
*/
export const round = (value, precision) => {
if (!value || isNaN(value)) {
return value;
}
// helper variables
let m, f, isHalf, sgn;
// making sure precision is integer
precision |= 0;
m = Math.pow(10, precision);
value *= m;
// sign of the number
sgn = +(value > 0) | -(value < 0);
// isHalf = value % 1 === 0.5 * sgn;
isHalf = Math.abs(value % 1) >= 0.4999999999854481;
f = Math.floor(value);
if (isHalf) {
// rounds .5 away from zero
value = f + (sgn > 0);
}
return (isHalf ? value : Math.round(value)) / m;
};
/**
* Returns a string representing a number in fixed point notation.
*/
export const toFixed = (value, fractionDigits = 0) => {
return Number(value).toFixed(Math.max(fractionDigits, 0));
};
/**
* Checks whether a value is within the provided range.
*
* Range is an array of two numbers, for example: [0, 15].
*/
export const inRange = (value, range) => {
return range && value >= range[0] && value <= range[1];
};
/**
* Walks over the object with ranges, comparing value against every range,
* and returns the key of the first matching range.
*
* Range is an array of two numbers, for example: [0, 15].
*/
export const keyOfMatchingRange = (value, ranges) => {
for (let rangeName of Object.keys(ranges)) {
const range = ranges[rangeName];
if (inRange(value, range)) {
return rangeName;
}
}
};
/**
* Get number of digits following the decimal point in a number
*/
export const numberOfDecimalDigits = (value) => {
if (Math.floor(value) !== value) {
return value.toString().split('.')[1].length || 0;
}
return 0;
};
-32
View File
@@ -1,32 +0,0 @@
import { clamp } from './math';
/**
* Returns random number between lowerBound exclusive and upperBound inclusive
*/
export const randomNumber = (lowerBound: number, upperBound: number) => {
return Math.random() * (upperBound - lowerBound) + lowerBound;
};
/**
* Returns random integer between lowerBound exclusive and upperBound inclusive
*/
export const randomInteger = (lowerBound: number, upperBound: number) => {
lowerBound = Math.ceil(lowerBound);
upperBound = Math.floor(upperBound);
return Math.floor(Math.random() * (upperBound - lowerBound) + lowerBound);
};
/**
* Returns random array element
*/
export const randomPick = <T>(array: T[]) => {
return array[Math.floor(Math.random() * array.length)];
};
/**
* Return 1 with probability P percent; otherwise 0
*/
export const randomProb = (probability: number) => {
const normalized = clamp(probability, 0, 100) / 100;
return Math.random() <= normalized;
};
-20
View File
@@ -1,20 +0,0 @@
/**
* @file
* @copyright 2021 Aleksej Komarov
* @license MIT
*/
import { classes } from './react';
describe('classes', () => {
test('empty', () => {
expect(classes([])).toBe('');
});
test('result contains inputs', () => {
const output = classes(['foo', 'bar', false, true, 0, 1, 'baz']);
expect(output).toContain('foo');
expect(output).toContain('bar');
expect(output).toContain('baz');
});
});
-68
View File
@@ -1,68 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Helper for conditionally adding/removing classes in React
*/
export const classes = (classNames: (string | BooleanLike)[]) => {
let className = '';
for (let i = 0; i < classNames.length; i++) {
const part = classNames[i];
if (typeof part === 'string') {
className += part + ' ';
}
}
return className;
};
/**
* Normalizes children prop, so that it is always an array of VDom
* elements.
*/
export const normalizeChildren = <T>(children: T | T[]) => {
if (Array.isArray(children)) {
return children.flat().filter((value) => value) as T[];
}
if (typeof children === 'object') {
return [children];
}
return [];
};
/**
* Shallowly checks if two objects are different.
* Credit: https://github.com/developit/preact-compat
*/
export const shallowDiffers = (a: object, b: object) => {
let i;
for (i in a) {
if (!(i in b)) {
return true;
}
}
for (i in b) {
if (a[i] !== b[i]) {
return true;
}
}
return false;
};
/**
* A common case in tgui, when you pass a value conditionally, these are
* the types that can fall through the condition.
*/
export type BooleanLike = number | boolean | null | undefined;
/**
* A helper to determine whether the object is renderable by React.
*/
export const canRender = (value: unknown) => {
// prettier-ignore
return value !== undefined
&& value !== null
&& typeof value !== 'boolean';
};
-68
View File
@@ -1,68 +0,0 @@
import {
Action,
applyMiddleware,
combineReducers,
createAction,
createStore,
Reducer,
} from './redux';
// Dummy Reducer
const counterReducer: Reducer<number, Action<string>> = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
};
// Dummy Middleware
const loggingMiddleware = (storeApi) => (next) => (action) => {
console.log('Middleware:', action);
return next(action);
};
// Dummy Action Creators
const increment = createAction('INCREMENT');
const decrement = createAction('DECREMENT');
describe('Redux implementation tests', () => {
test('createStore works', () => {
const store = createStore(counterReducer);
expect(store.getState()).toBe(0);
});
test('createStore with applyMiddleware works', () => {
const store = createStore(
counterReducer,
applyMiddleware(loggingMiddleware),
);
expect(store.getState()).toBe(0);
});
test('dispatch works', () => {
const store = createStore(counterReducer);
store.dispatch(increment());
expect(store.getState()).toBe(1);
store.dispatch(decrement());
expect(store.getState()).toBe(0);
});
test('combineReducers works', () => {
const rootReducer = combineReducers({
counter: counterReducer,
});
const store = createStore(rootReducer);
expect(store.getState()).toEqual({ counter: 0 });
});
test('createAction works', () => {
const incrementAction = increment();
expect(incrementAction).toEqual({ type: 'INCREMENT' });
const decrementAction = decrement();
expect(decrementAction).toEqual({ type: 'DECREMENT' });
});
});
@@ -1,73 +0,0 @@
/**
* This plugin saves overall about 10KB on the final bundle size, so it's
* sort of worth it.
*
* We are using a .cjs extension because:
*
* 1. Webpack CLI only supports CommonJS modules;
* 2. tgui-dev-server supports both, but we still need to signal NodeJS
* to import it as a CommonJS module, hence .cjs extension.
*
* We need to copy-paste the whole "multiline" function because we can't
* synchronously import an ES module from a CommonJS module.
*
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Removes excess whitespace and indentation from the string.
*/
const multiline = (str) => {
const lines = str.split('\n');
// Determine base indentation
let minIndent;
for (let line of lines) {
for (let indent = 0; indent < line.length; indent++) {
const char = line[indent];
if (char !== ' ') {
if (minIndent === undefined || indent < minIndent) {
minIndent = indent;
}
break;
}
}
}
if (!minIndent) {
minIndent = 0;
}
// Remove this base indentation and trim the resulting string
// from both ends.
return lines
.map((line) => line.substr(minIndent).trimRight())
.join('\n')
.trim();
};
const StringPlugin = (ref) => {
return {
visitor: {
TaggedTemplateExpression: (path) => {
if (path.node.tag.name === 'multiline') {
const { quasi } = path.node;
if (quasi.expressions.length > 0) {
throw new Error('Multiline tag does not support expressions!');
}
if (quasi.quasis.length > 1) {
throw new Error('Quasis is longer than 1');
}
const { value } = quasi.quasis[0];
value.raw = multiline(value.raw);
value.cooked = multiline(value.cooked);
path.replaceWith(quasi);
}
},
},
};
};
module.exports = {
__esModule: true,
default: StringPlugin,
};
-35
View File
@@ -1,35 +0,0 @@
import { createSearch, decodeHtmlEntities, toTitleCase } from './string';
describe('createSearch', () => {
it('matches search terms correctly', () => {
const search = createSearch('test', (obj: { value: string }) => obj.value);
const obj1 = { value: 'This is a test string.' };
const obj2 = { value: 'This is a different string.' };
const obj3 = { value: 'This is a test string.' };
const objects = [obj1, obj2, obj3];
expect(objects.filter(search)).toEqual([obj1, obj3]);
});
});
describe('toTitleCase', () => {
it('converts strings to title case correctly', () => {
expect(toTitleCase('hello world')).toBe('Hello World');
expect(toTitleCase('HELLO WORLD')).toBe('Hello World');
expect(toTitleCase('HeLLo wORLd')).toBe('Hello World');
expect(toTitleCase('a tale of two cities')).toBe('A Tale of Two Cities');
expect(toTitleCase('war and peace')).toBe('War and Peace');
});
});
describe('decodeHtmlEntities', () => {
it('decodes HTML entities and removes unnecessary HTML tags correctly', () => {
expect(decodeHtmlEntities('<br>')).toBe('\n');
expect(decodeHtmlEntities('<p>Hello World</p>')).toBe('Hello World');
expect(decodeHtmlEntities('&amp;')).toBe('&');
expect(decodeHtmlEntities('&#38;')).toBe('&');
expect(decodeHtmlEntities('&#x26;')).toBe('&');
});
});
-174
View File
@@ -1,174 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Creates a search terms matcher. Returns true if given string matches the search text.
*
* @example
* ```tsx
* type Thing = { id: string; name: string };
*
* const objects = [
* { id: '123', name: 'Test' },
* { id: '456', name: 'Test' },
* ];
*
* const search = createSearch('123', (obj: Thing) => obj.id);
*
* objects.filter(search); // returns [{ id: '123', name: 'Test' }]
* ```
*/
export function createSearch<TObj>(
searchText: string,
stringifier = (obj: TObj) => JSON.stringify(obj),
): (obj: TObj) => boolean {
const preparedSearchText = searchText.toLowerCase().trim();
return (obj) => {
if (!preparedSearchText) {
return true;
}
const str = stringifier(obj);
if (!str) {
return false;
}
return str.toLowerCase().includes(preparedSearchText);
};
}
/**
* Capitalizes a word and lowercases the rest.
*
* @example
* ```tsx
* capitalize('heLLo') // Hello
* ```
*/
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
/**
* Similar to capitalize, this takes a string and replaces all first letters
* of any words.
*
* @example
* ```tsx
* capitalizeAll('heLLo woRLd') // 'HeLLo WoRLd'
* ```
*/
export function capitalizeAll(str: string): string {
return str.replace(/(^\w{1})|(\s+\w{1})/g, (letter) => letter.toUpperCase());
}
/**
* Capitalizes only the first letter of the str, leaving others untouched.
*
* @example
* ```tsx
* capitalizeFirst('heLLo woRLd') // 'HeLLo woRLd'
* ```
*/
export function capitalizeFirst(str: string): string {
return str.replace(/^\w/, (letter) => letter.toUpperCase());
}
const WORDS_UPPER = ['Id', 'Tv'] as const;
const WORDS_LOWER = [
'A',
'An',
'And',
'As',
'At',
'But',
'By',
'For',
'For',
'From',
'In',
'Into',
'Near',
'Nor',
'Of',
'On',
'Onto',
'Or',
'The',
'To',
'With',
] as const;
/**
* Converts a string to title case.
*
* @example
* ```tsx
* toTitleCase('a tale of two cities') // 'A Tale of Two Cities'
* ```
*/
export function toTitleCase(str: string): string {
if (!str) return str;
let currentStr = str.replace(/([^\W_]+[^\s-]*) */g, (str) => {
return capitalize(str);
});
for (let word of WORDS_LOWER) {
const regex = new RegExp('\\s' + word + '\\s', 'g');
currentStr = currentStr.replace(regex, (str) => str.toLowerCase());
}
for (let word of WORDS_UPPER) {
const regex = new RegExp('\\b' + word + '\\b', 'g');
currentStr = currentStr.replace(regex, (str) => str.toLowerCase());
}
return currentStr;
}
const TRANSLATE_REGEX = /&(nbsp|amp|quot|lt|gt|apos|trade);/g;
const TRANSLATIONS = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
nbsp: ' ',
quot: '"',
trade: '™',
} as const;
/**
* Decodes HTML entities and removes unnecessary HTML tags.
*
* @example
* ```tsx
* decodeHtmlEntities('&amp;') // returns '&'
* decodeHtmlEntities('&lt;') // returns '<'
* ```
*/
export function decodeHtmlEntities(str: string): string {
if (!str) return str;
return (
str
// Newline tags
.replace(/<br>/gi, '\n')
.replace(/<\/?[a-z0-9-_]+[^>]*>/gi, '')
// Basic entities
.replace(TRANSLATE_REGEX, (match, entity) => TRANSLATIONS[entity])
// Decimal entities
.replace(/&#?([0-9]+);/gi, (match, numStr) => {
const num = parseInt(numStr, 10);
return String.fromCharCode(num);
})
// Hex entities
.replace(/&#x?([0-9a-f]+);/gi, (match, numStr) => {
const num = parseInt(numStr, 16);
return String.fromCharCode(num);
})
);
}
-68
View File
@@ -1,68 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Returns a function, that, as long as it continues to be invoked, will
* not be triggered. The function will be called after it stops being
* called for N milliseconds. If `immediate` is passed, trigger the
* function on the leading edge, instead of the trailing.
*/
export const debounce = <F extends (...args: any[]) => any>(
fn: F,
time: number,
immediate = false,
): ((...args: Parameters<F>) => void) => {
let timeout: ReturnType<typeof setTimeout> | null;
return (...args: Parameters<F>) => {
const later = () => {
timeout = null;
if (!immediate) {
fn(...args);
}
};
const callNow = immediate && !timeout;
clearTimeout(timeout!);
timeout = setTimeout(later, time);
if (callNow) {
fn(...args);
}
};
};
/**
* Returns a function, that, when invoked, will only be triggered at most once
* during a given window of time.
*/
export const throttle = <F extends (...args: any[]) => any>(
fn: F,
time: number,
): ((...args: Parameters<F>) => void) => {
let previouslyRun: number | null,
queuedToRun: ReturnType<typeof setTimeout> | null;
return function invokeFn(...args: Parameters<F>) {
const now = Date.now();
if (queuedToRun) {
clearTimeout(queuedToRun);
}
if (!previouslyRun || now - previouslyRun >= time) {
fn.apply(null, args);
previouslyRun = now;
} else {
queuedToRun = setTimeout(
() => invokeFn(...args),
time - (now - (previouslyRun ?? 0)),
);
}
};
};
/**
* Suspends an asynchronous function for N milliseconds.
*
* @param {number} time
*/
export const sleep = (time: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, time));
-41
View File
@@ -1,41 +0,0 @@
/**
* Helps visualize highly complex ui data on the fly.
* @example
* ```tsx
* const { data } = useBackend<CargoData>();
* logger.log(getShallowTypes(data));
* ```
*/
export function getShallowTypes(
data: Record<string, any>,
): Record<string, any> {
const output = {};
for (const key in data) {
if (Array.isArray(data[key])) {
const arr: any[] = data[key];
// Return the first array item if it exists
if (data[key].length > 0) {
output[key] = arr[0];
continue;
}
output[key] = 'emptyarray';
} else if (typeof data[key] === 'object' && data[key] !== null) {
// Please inspect it further and make a new type for it
output[key] = 'object (inspect) || Record<string, any>';
} else if (typeof data[key] === 'number') {
const num = Number(data[key]);
// 0 and 1 could be booleans from byond
if (num === 1 || num === 0) {
output[key] = `${num}, BooleanLike?`;
continue;
}
output[key] = data[key];
}
}
return output;
}
-6
View File
@@ -1,6 +0,0 @@
/**
* Returns the arguments of a function F as an array.
*/
// prettier-ignore
export type ArgumentsOf<F extends Function>
= F extends (...args: infer A) => unknown ? A : never;
-11
View File
@@ -1,11 +0,0 @@
import { createUuid } from './uuid';
describe('createUuid', () => {
it('generates a UUID v4 string', () => {
const uuid = createUuid();
expect(uuid).toHaveLength(36);
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
});
-24
View File
@@ -1,24 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/**
* Creates a UUID v4 string
*
* @example
* ```tsx
* createUuid(); // 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
* ```
*/
export function createUuid(): string {
let d = new Date().getTime();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
+1 -1
View File
@@ -6,7 +6,7 @@
import 'tgui/styles/main.scss';
import { setupGlobalEvents } from 'tgui/events';
import { setupGlobalEvents } from 'tgui-core/events';
import Benchmark from './lib/benchmark';
+8 -5
View File
@@ -1,14 +1,17 @@
{
"private": true,
"name": "tgui-bench",
"version": "5.0.1",
"version": "5.0.3",
"dependencies": {
"@fastify/static": "^6.12.0",
"@fastify/static": "^8.0.2",
"@types/react-dom": "^18.3.1",
"common": "workspace:*",
"fastify": "^3.29.5",
"fastify": "^5.1.0",
"lodash": "^4.17.21",
"platform": "^1.3.6",
"react": "^18.2.0",
"tgui": "workspace:*"
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tgui": "workspace:*",
"tgui-core": "^1.7.5"
}
}
@@ -1,5 +1,5 @@
import { Button } from 'tgui/components';
import { createRenderer } from 'tgui/renderer';
import { Button } from 'tgui-core/components';
const render = createRenderer();
+2 -2
View File
@@ -1,5 +1,5 @@
import { Flex } from 'tgui/components';
import { createRenderer } from 'tgui/renderer';
import { Flex } from 'tgui-core/components';
const render = createRenderer();
@@ -7,7 +7,7 @@ export const Default = () => {
const node = (
<Flex align="baseline">
<Flex.Item mr={1}>Text {Math.random()}</Flex.Item>
<Flex.Item grow={1} basis={0}>
<Flex.Item grow basis={0}>
Text {Math.random()}
</Flex.Item>
</Flex>
@@ -1,5 +1,5 @@
import { Stack } from 'tgui/components';
import { createRenderer } from 'tgui/renderer';
import { Stack } from 'tgui-core/components';
const render = createRenderer();
@@ -7,7 +7,7 @@ export const Default = () => {
const node = (
<Stack align="baseline">
<Stack.Item>Text {Math.random()}</Stack.Item>
<Stack.Item grow={1} basis={0}>
<Stack.Item grow basis={0}>
Text {Math.random()}
</Stack.Item>
</Stack>
@@ -1,5 +1,5 @@
import { Box, Tooltip } from 'tgui/components';
import { createRenderer } from 'tgui/renderer';
import { Box, Tooltip } from 'tgui-core/components';
const render = createRenderer();
+4 -3
View File
@@ -1,13 +1,14 @@
{
"private": true,
"name": "tgui-dev-server",
"version": "5.0.1",
"version": "5.0.3",
"type": "module",
"dependencies": {
"axios": "^1.7.4",
"@types/ws": "^8.5.13",
"axios": "^1.7.7",
"glob": "^7.2.3",
"source-map": "^0.7.4",
"stacktrace-parser": "^0.1.10",
"ws": "^8.17.1"
"ws": "^8.18.0"
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ class WebpackCompiler {
// and retrieve all necessary dependencies.
const requireFromRoot = createRequire(dirname(import.meta.url) + '/../..');
const webpack = await requireFromRoot('webpack');
const createConfig = await requireFromRoot('./webpack.config.js');
const createConfig = await requireFromRoot('./webpack.config.edge.js');
const config = createConfig({}, options);
// Inject the HMR plugin into the config if we're using it
if (options.hot) {
+6 -6
View File
@@ -4,7 +4,7 @@
* @license MIT
*/
import { Flex } from 'tgui/components';
import { Stack } from 'tgui-core/components';
export const Notifications = (props) => {
const { children } = props;
@@ -14,14 +14,14 @@ export const Notifications = (props) => {
const NotificationsItem = (props) => {
const { rightSlot, children } = props;
return (
<Flex align="center" className="Notification">
<Flex.Item className="Notification__content" grow={1}>
<Stack align="center" className="Notification">
<Stack.Item className="Notification__content" grow>
{children}
</Flex.Item>
</Stack.Item>
{rightSlot && (
<Flex.Item className="Notification__rightSlot">{rightSlot}</Flex.Item>
<Stack.Item className="Notification__rightSlot">{rightSlot}</Stack.Item>
)}
</Flex>
</Stack>
);
};
+1 -1
View File
@@ -4,8 +4,8 @@
* @license MIT
*/
import { Button, Section, Stack } from 'tgui/components';
import { Pane } from 'tgui/layouts';
import { Button, Section, Stack } from 'tgui-core/components';
import { NowPlayingWidget, useAudio } from './audio';
import { ChatPanel, ChatTabs } from './chat';
@@ -4,9 +4,15 @@
* @license MIT
*/
import { toFixed } from 'common/math';
import { useDispatch, useSelector } from 'tgui/backend';
import { Button, Collapsible, Flex, Knob, Section } from 'tgui/components';
import {
Button,
Collapsible,
Knob,
Section,
Stack,
} from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { useSettings } from '../settings';
import { selectAudio } from './selectors';
@@ -30,11 +36,11 @@ export const NowPlayingWidget = (props) => {
: upload_date;
return (
<Flex align="center">
<Stack align="center">
{(audio.playing && (
<Flex.Item
<Stack.Item
mx={0.5}
grow={1}
grow
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
@@ -45,41 +51,41 @@ export const NowPlayingWidget = (props) => {
<Collapsible title={title || 'Unknown Track'} color={'blue'}>
<Section>
{URL !== 'Song Link Hidden' && (
<Flex.Item grow={1} color="label">
<Stack.Item grow color="label">
URL: {URL}
</Flex.Item>
</Stack.Item>
)}
<Flex.Item grow={1} color="label">
<Stack.Item grow color="label">
Duration: {duration}
</Flex.Item>
</Stack.Item>
{Artist !== 'Song Artist Hidden' &&
Artist !== 'Unknown Artist' && (
<Flex.Item grow={1} color="label">
<Stack.Item grow color="label">
Artist: {Artist}
</Flex.Item>
</Stack.Item>
)}
{album !== 'Song Album Hidden' && album !== 'Unknown Album' && (
<Flex.Item grow={1} color="label">
<Stack.Item grow color="label">
Album: {album}
</Flex.Item>
</Stack.Item>
)}
{upload_date !== 'Song Upload Date Hidden' &&
upload_date !== 'Unknown Date' && (
<Flex.Item grow={1} color="label">
<Stack.Item grow color="label">
Uploaded: {date}
</Flex.Item>
</Stack.Item>
)}
</Section>
</Collapsible>
}
</Flex.Item>
</Stack.Item>
)) || (
<Flex.Item grow={1} color="label">
<Stack.Item grow color="label">
Nothing to play.
</Flex.Item>
</Stack.Item>
)}
{audio.playing && (
<Flex.Item mx={0.5} fontSize="0.9em">
<Stack.Item mx={0.5} fontSize="0.9em">
<Button
tooltip="Stop"
icon="stop"
@@ -89,9 +95,9 @@ export const NowPlayingWidget = (props) => {
})
}
/>
</Flex.Item>
</Stack.Item>
)}
<Flex.Item mx={0.5} fontSize="0.9em">
<Stack.Item mx={0.5} fontSize="0.9em">
<Knob
minValue={0}
maxValue={1}
@@ -105,7 +111,7 @@ export const NowPlayingWidget = (props) => {
})
}
/>
</Flex.Item>
</Flex>
</Stack.Item>
</Stack>
);
};
@@ -8,10 +8,19 @@ import { createLogger } from 'tgui/logging';
const logger = createLogger('AudioPlayer');
type CustomAudioElement = HTMLAudioElement & { stop: Function };
export class AudioPlayer {
node: CustomAudioElement;
playing: boolean;
volume: number;
options: { pitch?: number; start?: number; end?: number };
onPlaySubscribers: Function[];
onStopSubscribers: Function[];
playbackInterval: NodeJS.Timeout;
constructor() {
// Set up the HTMLAudioElement node
this.node = document.createElement('audio');
this.node = document.createElement('audio') as CustomAudioElement;
this.node.style.setProperty('display', 'none');
document.body.appendChild(this.node);
// Set up other properties
@@ -50,7 +59,9 @@ export class AudioPlayer {
return;
}
const shouldStop =
this.options.end > 0 && this.node.currentTime >= this.options.end;
this.options.end &&
this.options.end > 0 &&
this.node.currentTime >= this.options.end;
if (shouldStop) {
this.stop();
}
@@ -12,7 +12,7 @@ import {
Input,
Section,
Stack,
} from 'tgui/components';
} from 'tgui-core/components';
import {
moveChatPageLeft,
@@ -30,9 +30,10 @@ export const ChatPageSettings = (props) => {
return (
<Section>
<Stack align="center">
<Stack.Item grow={1}>
<Stack.Item grow>
<Input
fluid
updateOnPropsChange
value={page.name}
onChange={(e, value) =>
dispatch(
@@ -116,7 +117,7 @@ export const ChatPageSettings = (props) => {
)}
</Stack>
<Divider />
<Section title="Messages to display" level={2}>
<Section title="Messages to display">
{MESSAGE_TYPES.filter(
(typeDef) => !typeDef.important && !typeDef.admin,
).map((typeDef) => (
@@ -4,13 +4,21 @@
* @license MIT
*/
import { shallowDiffers } from 'common/react';
import { Component, createRef } from 'react';
import { Button } from 'tgui/components';
import { Component, createRef, RefObject } from 'react';
import { Button } from 'tgui-core/components';
import { shallowDiffers } from 'tgui-core/react';
import { chatRenderer } from './renderer';
export class ChatPanel extends Component {
type ChatPanelTypes = {
lineHeight?: number;
fontSize?: number;
};
export class ChatPanel extends Component<ChatPanelTypes> {
ref: RefObject<HTMLDivElement>;
handleScrollTrackingChange: (value: any) => void;
state: { scrollTracking: boolean };
constructor(props) {
super(props);
this.ref = createRef();
@@ -39,7 +47,7 @@ export class ChatPanel extends Component {
);
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps?) {
requestAnimationFrame(() => {
chatRenderer.ensureScrollTracking();
});
@@ -5,7 +5,7 @@
*/
import { useDispatch, useSelector } from 'tgui/backend';
import { Box, Button, Flex, Tabs } from 'tgui/components';
import { Box, Button, Stack, Tabs } from 'tgui-core/components';
import { openChatSettings } from '../settings/actions';
import { addChatPage, changeChatPage } from './actions';
@@ -31,8 +31,8 @@ export const ChatTabs = (props) => {
const currentPage = useSelector(selectCurrentChatPage);
const dispatch = useDispatch();
return (
<Flex align="center">
<Flex.Item>
<Stack align="center">
<Stack.Item>
<Tabs textAlign="center">
{pages.map((page) => (
<Tabs.Tab
@@ -56,8 +56,8 @@ export const ChatTabs = (props) => {
</Tabs.Tab>
))}
</Tabs>
</Flex.Item>
<Flex.Item ml={1}>
</Stack.Item>
<Stack.Item ml={1}>
<Button
color="transparent"
icon="plus"
@@ -66,7 +66,7 @@ export const ChatTabs = (props) => {
dispatch(openChatSettings());
}}
/>
</Flex.Item>
</Flex>
</Stack.Item>
</Stack>
);
};
@@ -4,6 +4,7 @@
* @license MIT
*/
import type { Action, Store } from 'common/redux';
import { storage } from 'common/storage';
import DOMPurify from 'dompurify';
@@ -33,13 +34,14 @@ import {
import { createMessage, serializeMessage } from './model';
import { chatRenderer } from './renderer';
import { selectChat, selectCurrentChatPage } from './selectors';
import { message } from './types';
// List of blacklisted tags
const blacklisted_tags = ['a', 'iframe', 'link', 'video'];
let storedRounds = [];
let storedLines = [];
let storedRounds: number[] = [];
let storedLines: number[] = [];
const saveChatToStorage = async (store) => {
const saveChatToStorage = async (store: Store<number, Action<string>>) => {
const state = selectChat(store.getState());
const settings = selectSettings(store.getState());
const fromIndex = Math.max(
@@ -57,7 +59,7 @@ const saveChatToStorage = async (store) => {
); // FIXME: Better chat history
};
const loadChatFromStorage = async (store) => {
const loadChatFromStorage = async (store: Store<number, Action<string>>) => {
const [state, messages, archivedMessages] = await Promise.all([
storage.get('chat-state'),
storage.get('chat-messages'),
@@ -87,7 +89,7 @@ const loadChatFromStorage = async (store) => {
});
}
if (archivedMessages) {
for (let archivedMessage of archivedMessages) {
for (let archivedMessage of archivedMessages as message[]) {
if (archivedMessage.html) {
archivedMessage.html = DOMPurify.sanitize(archivedMessage.html, {
FORBID_TAGS: blacklisted_tags,
@@ -101,18 +103,18 @@ const loadChatFromStorage = async (store) => {
if (settings.logRetainRounds) {
storedRounds = [];
storedLines = [];
let oldId = null;
let currentLine = 0;
let oldId: number | null = null;
let currentLine: number = 0;
settings.storedRounds = 0;
settings.exportStart = 0;
settings.exportEnd = 0;
for (let message of archivedMessages) {
const currentId = message.roundId;
for (let message of archivedMessages as message[]) {
const currentId = message.roundId || 0;
if (currentId !== oldId) {
const round = currentId;
const line = currentLine;
storedRounds.push(round);
storedRounds.push(round || 0);
storedLines.push(line);
oldId = currentId;
currentLine++;
@@ -137,8 +139,8 @@ const loadChatFromStorage = async (store) => {
export const chatMiddleware = (store) => {
let initialized = false;
let loaded = false;
const sequences = [];
const sequences_requested = [];
const sequences: number[] = [];
const sequences_requested: number[] = [];
chatRenderer.events.on('batchProcessed', (countByType) => {
// Use this flag to workaround unread messages caused by
// loading them from storage. Side effect of that, is that
@@ -206,7 +208,7 @@ export const chatMiddleware = (store) => {
requesting < sequence;
requesting++
) {
requested_sequences.push(requesting);
sequences_requested.push(requesting);
Byond.sendMessage('chat/resend', requesting);
}
}
@@ -4,14 +4,15 @@
* @license MIT
*/
import { createUuid } from 'common/uuid';
import { createUuid } from 'tgui-core/uuid';
import { MESSAGE_TYPE_INTERNAL, MESSAGE_TYPES } from './constants';
import { message, Page } from './types';
export const canPageAcceptType = (page, type) =>
export const canPageAcceptType = (page: Page, type: string): string | boolean =>
type.startsWith(MESSAGE_TYPE_INTERNAL) || page.acceptedTypes[type];
export const typeIsImportant = (type) => {
export const typeIsImportant = (type: string): boolean => {
let isImportant = false;
for (let typeDef of MESSAGE_TYPES) {
if (typeDef.type === type && !!typeDef.important) {
@@ -22,7 +23,7 @@ export const typeIsImportant = (type) => {
return isImportant;
};
export const adminPageOnly = (page) => {
export const adminPageOnly = (page: Page): boolean => {
let adminTab = true;
let checked = 0;
for (let typeDef of MESSAGE_TYPES) {
@@ -40,9 +41,10 @@ export const adminPageOnly = (page) => {
return checked > 0 && adminTab;
};
export const canStoreType = (storedTypes, type) => storedTypes[type];
export const canStoreType = (storedTypes: Object, type: string) =>
storedTypes[type];
export const createPage = (obj) => {
export const createPage = (obj?: Object): Page => {
let acceptedTypes = {};
for (let typeDef of MESSAGE_TYPES) {
@@ -61,7 +63,7 @@ export const createPage = (obj) => {
};
};
export const createMainPage = () => {
export const createMainPage = (): Page => {
const acceptedTypes = {};
for (let typeDef of MESSAGE_TYPES) {
acceptedTypes[typeDef.type] = true;
@@ -73,15 +75,18 @@ export const createMainPage = () => {
});
};
export const createMessage = (payload) => ({
export const createMessage = (payload: { type: string }): message => ({
...payload,
createdAt: Date.now(),
roundId: null,
...payload,
});
export const serializeMessage = (message, archive = false) => {
export const serializeMessage = (
message: message,
archive = false,
): message => {
let archiveM = '';
if (archive) {
if (archive && message.node && typeof message.node !== 'string') {
archiveM = message.node.outerHTML.replace(/(?:\r\n|\r|\n)/g, '<br>');
}
return {
@@ -94,6 +99,6 @@ export const serializeMessage = (message, archive = false) => {
};
};
export const isSameMessage = (a, b) =>
export const isSameMessage = (a: message, b: message): boolean =>
(typeof a.text === 'string' && a.text === b.text) ||
(typeof a.html === 'string' && a.html === b.html);
@@ -4,12 +4,12 @@
* @license MIT
*/
import { EventEmitter } from 'common/events';
import { classes } from 'common/react';
import { createRoot } from 'react-dom/client';
import { createLogger } from 'tgui/logging';
import { Tooltip } from 'tgui-core/components';
import { EventEmitter } from 'tgui-core/events';
import { classes } from 'tgui-core/react';
import { Tooltip } from '../../tgui/components';
import {
IMAGE_RETRY_DELAY,
IMAGE_RETRY_LIMIT,
@@ -29,6 +29,7 @@ import {
typeIsImportant,
} from './model';
import { highlightNode, linkifyNode } from './replaceInTextNode';
import { message } from './types';
const logger = createLogger('chatRenderer');
@@ -48,7 +49,7 @@ export const TGUI_CHAT_ATTRIBUTES_TO_PROPS = {
content: 'content',
};
const findNearestScrollableParent = (startingNode) => {
const findNearestScrollableParent = (startingNode: HTMLElement) => {
const body = document.body;
let node = startingNode;
while (node && node !== body) {
@@ -58,12 +59,12 @@ const findNearestScrollableParent = (startingNode) => {
if (node.scrollWidth < node.offsetWidth) {
return node;
}
node = node.parentNode;
node = node.parentNode as HTMLElement;
}
return window;
};
const createHighlightNode = (text, color) => {
const createHighlightNode = (text: string, color: string): HTMLElement => {
const node = document.createElement('span');
node.className = 'Chat__highlight';
node.setAttribute('style', 'background-color:' + color);
@@ -71,13 +72,17 @@ const createHighlightNode = (text, color) => {
return node;
};
const createMessageNode = () => {
const createMessageNode = (): HTMLElement => {
const node = document.createElement('div');
node.className = 'ChatMessage';
return node;
};
const interleaveMessage = (node, interleave, color) => {
const interleaveMessage = (
node: HTMLElement,
interleave: boolean,
color: string,
): HTMLElement => {
if (interleave) {
node.setAttribute('style', 'background-color:' + color);
node.setAttribute('display', 'block');
@@ -88,20 +93,20 @@ const interleaveMessage = (node, interleave, color) => {
return node;
};
const stripNewLineFlood = (text) => {
const stripNewLineFlood = (text: string): string => {
text = text.replace(/((\n)\2{2})\2+/g, '$1');
return text;
};
const createReconnectedNode = () => {
const createReconnectedNode = (): HTMLElement => {
const node = document.createElement('div');
node.className = 'Chat__reconnected';
return node;
};
const getChatTimestamp = (message) => {
const getChatTimestamp = (message: message): string => {
let stamp = '';
if (message.createdAt && !message.hasTimestamp) {
if (message.createdAt) {
const dateTime = new Date(message.createdAt);
stamp =
'[' +
@@ -114,34 +119,37 @@ const getChatTimestamp = (message) => {
return stamp;
};
const handleImageError = (e) => {
const handleImageError = (e: ErrorEvent) => {
setTimeout(() => {
/** @type {HTMLImageElement} */
const node = e.target;
const attempts = parseInt(node.getAttribute('data-reload-n'), 10) || 0;
if (attempts >= IMAGE_RETRY_LIMIT) {
logger.error(`failed to load an image after ${attempts} attempts`);
return;
const node = e.target as HTMLImageElement;
if (node) {
const attempts =
parseInt(node.getAttribute('data-reload-n') || '', 10) || 0;
if (attempts >= IMAGE_RETRY_LIMIT) {
logger.error(`failed to load an image after ${attempts} attempts`);
return;
}
const src = node.src;
node.src = '';
node.src = src + '#' + attempts;
node.setAttribute('data-reload-n', (attempts + 1).toString());
}
const src = node.src;
node.src = null;
node.src = src + '#' + attempts;
node.setAttribute('data-reload-n', attempts + 1);
}, IMAGE_RETRY_DELAY);
};
/**
* Assigns a "times-repeated" badge to the message.
*/
const updateMessageBadge = (message) => {
const updateMessageBadge = (message: message) => {
const { node, times } = message;
if (!node || !times) {
if (!node || !times || typeof node === 'string') {
// Nothing to update
return;
}
const foundBadge = node.querySelector('.Chat__badge');
const foundBadge = (node as HTMLElement).querySelector('.Chat__badge');
const badge = foundBadge || document.createElement('div');
badge.textContent = times;
badge.textContent = times.toString();
badge.className = classes(['Chat__badge', 'Chat__badge--animate']);
requestAnimationFrame(() => {
badge.className = 'Chat__badge';
@@ -152,6 +160,41 @@ const updateMessageBadge = (message) => {
};
class ChatRenderer {
loaded: boolean;
rootNode: HTMLElement | null;
queue: string[];
messages: message[];
archivedMessages: message[];
visibleMessages: message[];
page: null;
events: EventEmitter;
prependTimestamps: boolean;
visibleMessageLimit: number;
combineMessageLimit: number;
combineIntervalLimit: number;
persistentMessageLimit: number;
logLimit: number;
logEnable: boolean;
roundId: null | number;
storedTypes: {};
interleave: boolean;
interleaveEnabled: boolean;
interleaveColor: string;
hideImportantInAdminTab: boolean;
scrollNode: HTMLElement | null;
scrollTracking: boolean;
handleScroll: (type: any) => void;
ensureScrollTracking: () => void;
highlightParsers:
| {
highlightWords: string;
highlightRegex: RegExp;
highlightColor: string;
highlightWholeMessage: boolean;
highlightBlacklist: string;
blacklistregex: RegExp;
}[]
| null;
constructor() {
/** @type {HTMLElement} */
this.loaded = false;
@@ -182,14 +225,16 @@ class ChatRenderer {
this.scrollTracking = true;
this.handleScroll = (type) => {
const node = this.scrollNode;
const height = node.scrollHeight;
const bottom = node.scrollTop + node.offsetHeight;
const scrollTracking =
Math.abs(height - bottom) < SCROLL_TRACKING_TOLERANCE;
if (scrollTracking !== this.scrollTracking) {
this.scrollTracking = scrollTracking;
this.events.emit('scrollTrackingChanged', scrollTracking);
logger.debug('tracking', this.scrollTracking);
if (node) {
const height = node.scrollHeight;
const bottom = node.scrollTop + node.offsetHeight;
const scrollTracking =
Math.abs(height - bottom) < SCROLL_TRACKING_TOLERANCE;
if (scrollTracking !== this.scrollTracking) {
this.scrollTracking = scrollTracking;
this.events.emit('scrollTrackingChanged', scrollTracking);
logger.debug('tracking', this.scrollTracking);
}
}
};
this.ensureScrollTracking = () => {
@@ -215,8 +260,14 @@ class ChatRenderer {
this.rootNode = node;
}
// Find scrollable parent
this.scrollNode = findNearestScrollableParent(this.rootNode);
this.scrollNode.addEventListener('scroll', this.handleScroll);
if (this.rootNode) {
this.scrollNode = findNearestScrollableParent(
this.rootNode,
) as HTMLElement;
}
if (this.scrollNode) {
this.scrollNode.addEventListener('scroll', this.handleScroll);
}
setTimeout(() => {
this.scrollToBottom();
});
@@ -238,7 +289,9 @@ class ChatRenderer {
assignStyle(style = {}) {
for (let key of Object.keys(style)) {
this.rootNode.style.setProperty(key, style[key]);
if (this.rootNode) {
this.rootNode.style.setProperty(key, style[key]);
}
}
}
@@ -293,7 +346,7 @@ class ChatRenderer {
let blacklistWords;
let blacklistregex;
if (highlightBlacklist && blacklistLines.length > 0) {
let blacklistRegexExpressions = [];
let blacklistRegexExpressions: string[] = [];
for (let line of blacklistLines) {
// Regex expression syntax is /[exp]/
if (line.charAt(0) === '/' && line.charAt(line.length - 1) === '/') {
@@ -326,7 +379,7 @@ class ChatRenderer {
blacklistregex = null;
}
}
let regexExpressions = [];
let regexExpressions: string[] = [];
// Organize each highlight entry into regex expressions and words
for (let line of lines) {
// Regex expression syntax is /[exp]/
@@ -384,21 +437,23 @@ class ChatRenderer {
scrollToBottom() {
// scrollHeight is always bigger than scrollTop and is
// automatically clamped to the valid range.
this.scrollNode.scrollTop = this.scrollNode.scrollHeight;
if (this.scrollNode) {
this.scrollNode.scrollTop = this.scrollNode.scrollHeight;
}
}
setVisualChatLimits(
visibleMessageLimit,
combineMessageLimit,
combineIntervalLimit,
logEnable,
logLimit,
storedTypes,
roundId,
prependTimestamps,
hideImportantInAdminTab,
interleaveEnabled,
interleaveColor,
visibleMessageLimit: number,
combineMessageLimit: number,
combineIntervalLimit: number,
logEnable: boolean,
logLimit: number,
storedTypes: {},
roundId: number | null,
prependTimestamps: boolean,
hideImportantInAdminTab: boolean,
interleaveEnabled: boolean,
interleaveColor: string,
) {
this.visibleMessageLimit = visibleMessageLimit;
this.combineMessageLimit = combineMessageLimit;
@@ -421,7 +476,9 @@ class ChatRenderer {
}
this.page = page;
// Fast clear of the root node
this.rootNode.textContent = '';
if (this.rootNode) {
this.rootNode.textContent = '';
}
this.visibleMessages = [];
// Re-add message nodes
const fragment = document.createDocumentFragment();
@@ -446,7 +503,7 @@ class ChatRenderer {
this.visibleMessages.push(message);
}
}
if (node) {
if (node && this.rootNode) {
this.rootNode.appendChild(fragment);
node.scrollIntoView();
}
@@ -474,7 +531,14 @@ class ChatRenderer {
return null;
}
processBatch(batch, options = {}) {
processBatch(
batch,
options: {
prepend?: boolean;
notifyListeners?: boolean;
doArchive?: boolean;
} = {},
) {
const { prepend, notifyListeners = true, doArchive = false } = options;
const now = Date.now();
// Queue up messages until chat is ready
@@ -648,6 +712,7 @@ class ChatRenderer {
this.archivedMessages.push(serializeMessage(message, true)); // TODO: Actually having a better message archiving maybe for exports?
}
if (
this.page &&
canPageAcceptType(this.page, message.type) &&
!(
adminPageOnly(this.page) &&
@@ -665,7 +730,7 @@ class ChatRenderer {
this.visibleMessages.push(message);
}
}
if (node) {
if (node && this.rootNode) {
const firstChild = this.rootNode.childNodes[0];
if (prepend && firstChild) {
this.rootNode.insertBefore(fragment, firstChild);
@@ -700,7 +765,9 @@ class ChatRenderer {
this.visibleMessages = messages.slice(fromIndex);
for (let i = 0; i < fromIndex; i++) {
const message = messages[i];
this.rootNode.removeChild(message.node);
if (this.rootNode && message.node) {
this.rootNode.removeChild(message.node as Node);
}
// Mark this message as pruned
message.node = 'pruned';
}
@@ -737,7 +804,9 @@ class ChatRenderer {
message.node = undefined;
}
// Fast clear of the root node
this.rootNode.textContent = '';
if (this.rootNode) {
this.rootNode.textContent = '';
}
this.messages = [];
this.visibleMessages = [];
// Repopulate the chat log
@@ -763,7 +832,7 @@ class ChatRenderer {
// Compile chat log as HTML text
let messagesHtml = '';
let tmpMsgArray = [];
let tmpMsgArray: message[] = [];
if (startLine || endLine) {
if (!endLine) {
tmpMsgArray = this.archivedMessages.slice(startLine);
@@ -782,7 +851,7 @@ class ChatRenderer {
// for (let message of this.visibleMessages) { // TODO: Actually having a better message archiving maybe for exports?
for (let message of tmpMsgArray) {
// Filter messages according to active tab for export
if (canPageAcceptType(this.page, message.type)) {
if (this.page && canPageAcceptType(this.page, message.type)) {
messagesHtml += message.html + '\n';
}
// if (message.node) {
@@ -825,11 +894,18 @@ class ChatRenderer {
}
}
type ChatWindow = Window &
typeof globalThis & {
__chatRenderer__: ChatRenderer;
};
// Make chat renderer global so that we can continue using the same
// instance after hot code replacement.
if (!window.__chatRenderer__) {
window.__chatRenderer__ = new ChatRenderer();
const chatWindow = window as ChatWindow;
if (!chatWindow.__chatRenderer__) {
chatWindow.__chatRenderer__ = new ChatRenderer();
}
/** @type {ChatRenderer} */
export const chatRenderer = window.__chatRenderer__;
export const chatRenderer = chatWindow.__chatRenderer__;
@@ -7,9 +7,14 @@
/**
* Replaces text matching a regular expression with a custom node.
*/
const regexParseNode = (params) => {
const regexParseNode = (params: {
node: Node;
regex: RegExp;
createNode: (text: string) => Node;
captureAdjust?: (str: string) => string;
}): { nodes?: HTMLElement; n?: number } => {
const { node, regex, createNode, captureAdjust } = params;
const text = node.textContent;
const text = node.textContent || '';
const textLength = text.length;
let nodes;
let new_node;
@@ -59,7 +64,9 @@ const regexParseNode = (params) => {
fragment.appendChild(new_node);
}
// Commit the fragment
node.parentNode.replaceChild(fragment, node);
if (node && node.parentNode) {
node.parentNode.replaceChild(fragment, node);
}
}
return {
@@ -72,57 +79,63 @@ const regexParseNode = (params) => {
* Replace text of a node with custom nades if they match
* a regex expression or are in a word list
*/
export const replaceInTextNode = (regex, words, createNode) => (node) => {
let nodes;
let result;
let n = 0;
export const replaceInTextNode =
(
regex: RegExp,
words: string | null,
createNode: (text: string) => Node,
): ((node: Node) => number) =>
(node: Node) => {
let nodes;
let result;
let n = 0;
if (regex) {
result = regexParseNode({
node: node,
regex: regex,
createNode: createNode,
});
nodes = result.nodes;
n += result.n;
}
if (words) {
let i = 0;
let wordRegexStr = '(';
for (let word of words) {
// Capture if the word is at the beginning, end, middle,
// or by itself in a message
wordRegexStr += `^${word}\\W|\\W${word}\\W|\\W${word}$|^${word}$`;
// Make sure the last character for the expression is NOT '|'
if (++i !== words.length) {
wordRegexStr += '|';
}
if (regex) {
result = regexParseNode({
node: node,
regex: regex,
createNode: createNode,
});
nodes = result.nodes;
n += result.n;
}
wordRegexStr += ')';
const wordRegex = new RegExp(wordRegexStr, 'gi');
if (regex && nodes) {
for (let a_node of nodes) {
if (words) {
let i = 0;
let wordRegexStr = '(';
for (let word of words) {
// Capture if the word is at the beginning, end, middle,
// or by itself in a message
wordRegexStr += `^${word}\\W|\\W${word}\\W|\\W${word}$|^${word}$`;
// Make sure the last character for the expression is NOT '|'
if (++i !== words.length) {
wordRegexStr += '|';
}
}
wordRegexStr += ')';
const wordRegex = new RegExp(wordRegexStr, 'gi');
if (regex && nodes) {
for (let a_node of nodes) {
result = regexParseNode({
node: a_node,
regex: wordRegex,
createNode: createNode,
captureAdjust: (str: string) => str.replace(/^\W|\W$/g, ''),
});
n += result.n;
}
} else {
result = regexParseNode({
node: a_node,
node: node,
regex: wordRegex,
createNode: createNode,
captureAdjust: (str) => str.replace(/^\W|\W$/g, ''),
captureAdjust: (str: string) => str.replace(/^\W|\W$/g, ''),
});
n += result.n;
}
} else {
result = regexParseNode({
node: node,
regex: wordRegex,
createNode: createNode,
captureAdjust: (str) => str.replace(/^\W|\W$/g, ''),
});
n += result.n;
}
}
return n;
};
return n;
};
// Highlight
// --------------------------------------------------------
@@ -130,7 +143,7 @@ export const replaceInTextNode = (regex, words, createNode) => (node) => {
/**
* Default highlight node.
*/
const createHighlightNode = (text) => {
const createHighlightNode = (text: string): HTMLSpanElement => {
const node = document.createElement('span');
node.setAttribute('style', 'background-color:#fd4;color:#000');
node.textContent = text;
@@ -146,10 +159,10 @@ const createHighlightNode = (text) => {
* @returns {number} Number of matches
*/
export const highlightNode = (
node,
regex,
words,
createNode = createHighlightNode,
node: Node,
regex: RegExp,
words: string,
createNode: (text: string) => Node = createHighlightNode,
) => {
if (!createNode) {
createNode = createHighlightNode;
@@ -180,7 +193,7 @@ const URL_REGEX =
* @param {Node} node Node which you want to process
* @returns {number} Number of matches
*/
export const linkifyNode = (node) => {
export const linkifyNode = (node: Node): number => {
let n = 0;
const childNodes = node.childNodes;
for (let i = 0; i < childNodes.length; i++) {
+2 -1
View File
@@ -14,4 +14,5 @@ export const selectChatPages = (state) =>
export const selectCurrentChatPage = (state) =>
state.chat.pageById[state.chat.currentPageId];
export const selectChatPageById = (id) => (state) => state.chat.pageById[id];
export const selectChatPageById = (id: string) => (state) =>
state.chat.pageById[id];
+20
View File
@@ -0,0 +1,20 @@
export type message = {
node?: Element | string;
type: string;
text?: string;
html?: string;
times?: number;
createdAt: number;
roundId: number | null;
avoidHighlighting?: boolean;
};
export type Page = {
isMain: boolean;
id: string;
name: string;
acceptedTypes: Record<string, boolean>;
unreadCount: number;
hideUnreadCount: boolean;
createdAt: number;
};
+1 -1
View File
@@ -13,10 +13,10 @@ import './styles/themes/vchatdark.scss';
import { perf } from 'common/perf';
import { combineReducers } from 'common/redux';
import { setGlobalStore } from 'tgui/backend';
import { setupGlobalEvents } from 'tgui/events';
import { captureExternalLinks } from 'tgui/links';
import { createRenderer } from 'tgui/renderer';
import { configureStore } from 'tgui/store';
import { setupGlobalEvents } from 'tgui-core/events';
import { setupHotReloading } from 'tgui-dev-server/link/client.cjs';
import { audioMiddleware, audioReducer } from './audio';
+7 -6
View File
@@ -1,15 +1,16 @@
{
"private": true,
"name": "tgui-panel",
"version": "5.0.1",
"version": "5.0.3",
"dependencies": {
"@types/node": "^20.12.3",
"@types/react": "^18.2.74",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"common": "workspace:*",
"dompurify": "^2.5.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"dompurify": "^2.5.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tgui": "workspace:*",
"tgui-core": "^1.7.5",
"tgui-dev-server": "workspace:*",
"tgui-polyfill": "workspace:*"
}
@@ -8,8 +8,8 @@
*/
import { vecLength, vecSubtract } from 'common/vector';
import { canStealFocus, globalEvents } from 'tgui/events';
import { focusMap } from 'tgui/focus';
import { canStealFocus, globalEvents } from 'tgui-core/events';
// Empyrically determined number for the smallest possible
// text you can select with the mouse.
@@ -18,10 +18,10 @@ const MIN_SELECTION_DISTANCE = 10;
const deferredFocusMap = () => setTimeout(() => focusMap());
export const setupPanelFocusHacks = () => {
let focusStolen = false;
let clickStartPos = null;
let focusStolen: boolean = false;
let clickStartPos: number[] | null = null;
window.addEventListener('focusin', (e) => {
focusStolen = canStealFocus(e.target);
focusStolen = canStealFocus(e.target as HTMLElement);
});
window.addEventListener('mousedown', (e) => {
clickStartPos = [e.screenX, e.screenY];
@@ -4,10 +4,11 @@
* @license MIT
*/
import { Color } from 'common/color';
import { toFixed } from 'common/math';
import { useSelector } from 'tgui/backend';
import { Box } from 'tgui/components';
import { Color } from 'tgui-core/color';
import { Box } from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { selectPing } from './selectors';
export const PingIndicator = (props) => {
@@ -16,7 +17,7 @@ export const PingIndicator = (props) => {
new Color(220, 40, 40),
new Color(220, 200, 40),
new Color(60, 220, 40),
]);
]).toString();
const roundtrip = ping.roundtrip ? toFixed(ping.roundtrip) : '--';
return (
<div className="Ping">
@@ -10,7 +10,7 @@ import { PING_QUEUE_SIZE, PING_TIMEOUT } from './constants';
export const pingMiddleware = (store) => {
let initialized = false;
let index = 0;
const pings = [];
const pings: ({ sentAt: number } | null)[] = [];
const sendPing = () => {
for (let i = 0; i < PING_QUEUE_SIZE; i++) {
+1 -1
View File
@@ -4,7 +4,7 @@
* @license MIT
*/
import { clamp01, scale } from 'common/math';
import { clamp01, scale } from 'tgui-core/math';
import { pingFail, pingSuccess } from './actions';
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { useDispatch } from 'tgui/backend';
import { Button } from 'tgui/components';
import { Button } from 'tgui-core/components';
import { dismissWarning } from './game/actions';
@@ -1,5 +1,5 @@
import { useDispatch, useSelector } from 'tgui/backend';
import { Button, LabeledList, Section } from 'tgui/components';
import { Button, LabeledList, Section } from 'tgui-core/components';
import { updateSettings } from '../actions';
import { selectSettings } from '../selectors';
@@ -1,4 +1,3 @@
import { toFixed } from 'common/math';
import { useState } from 'react';
import { useDispatch, useSelector } from 'tgui/backend';
import {
@@ -6,11 +5,12 @@ import {
Button,
Collapsible,
Divider,
Flex,
LabeledList,
NumberInput,
Section,
} from 'tgui/components';
Stack,
} from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { purgeChatMessageArchive, saveChatToDisk } from '../../chat/actions';
import { MESSAGE_TYPES } from '../../chat/constants';
@@ -36,7 +36,7 @@ export const ExportTab = (props) => {
const [logConfirm, setLogConfirm] = useState(false);
return (
<Section>
<Flex align="baseline">
<Stack align="baseline">
{logEnable ? (
logConfirm ? (
<Button
@@ -82,12 +82,12 @@ export const ExportTab = (props) => {
Enable logging
</Button>
)}
<Flex.Item grow={1} />
<Flex.Item color="label">Round ID:&nbsp;</Flex.Item>
<Flex.Item color={game.roundId ? '' : 'red'}>
<Stack.Item grow />
<Stack.Item color="label">Round ID:&nbsp;</Stack.Item>
<Stack.Item color={game.roundId ? '' : 'red'}>
{game.roundId ? game.roundId : 'ERROR'}
</Flex.Item>
</Flex>
</Stack.Item>
</Stack>
{logEnable ? (
<>
<LabeledList>
@@ -1,6 +1,6 @@
import { toFixed } from 'common/math';
import { useDispatch, useSelector } from 'tgui/backend';
import { Box, LabeledList, NumberInput, Section } from 'tgui/components';
import { Box, LabeledList, NumberInput, Section } from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { updateSettings } from '../actions';
import { selectSettings } from '../selectors';
@@ -1,17 +1,18 @@
import { toFixed } from 'common/math';
import { useState } from 'react';
import { useDispatch, useSelector } from 'tgui/backend';
import {
Box,
Button,
Collapsible,
ColorBox,
Dropdown,
Input,
LabeledList,
NumberInput,
Section,
Slider,
Stack,
} from 'tgui/components';
} from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { capitalize } from 'tgui-core/string';
import { rebuildChat } from '../../chat/actions';
import { THEMES } from '../../themes';
@@ -36,39 +37,63 @@ export const SettingsGeneral = (props) => {
<Section>
<LabeledList>
<LabeledList.Item label="Theme">
<Dropdown
autoScroll={false}
width="175px"
selected={theme}
options={THEMES}
onSelected={(value) =>
dispatch(
updateSettings({
theme: value,
}),
)
}
/>
{THEMES.map((THEME) => (
<Button
key={THEME}
selected={theme === THEME}
color="transparent"
onClick={() =>
dispatch(
updateSettings({
theme: THEME,
}),
)
}
>
{capitalize(THEME)}
</Button>
))}
</LabeledList.Item>
<LabeledList.Item label="Font style">
<Stack inline align="baseline">
<Stack.Item>
{(!freeFont && (
<Dropdown
autoScroll={false}
width="175px"
selected={fontFamily}
options={FONTS}
onSelected={(value) =>
dispatch(
updateSettings({
fontFamily: value,
}),
)
}
/>
)) || (
<Stack.Item>
{!freeFont ? (
<Collapsible
title={fontFamily}
width={'100%'}
buttons={
<Button
icon={freeFont ? 'lock-open' : 'lock'}
color={freeFont ? 'good' : 'bad'}
onClick={() => {
setFreeFont(!freeFont);
}}
>
Custom font
</Button>
}
>
{FONTS.map((FONT) => (
<Button
key={FONT}
fontFamily={FONT}
selected={fontFamily === FONT}
color="transparent"
onClick={() =>
dispatch(
updateSettings({
fontFamily: FONT,
}),
)
}
>
{FONT}
</Button>
))}
</Collapsible>
) : (
<Stack>
<Input
width={'100%'}
value={fontFamily}
onChange={(e, value) =>
dispatch(
@@ -78,51 +103,48 @@ export const SettingsGeneral = (props) => {
)
}
/>
)}
</Stack.Item>
<Stack.Item>
<Button
icon={freeFont ? 'lock-open' : 'lock'}
color={freeFont ? 'good' : 'bad'}
ml={1}
onClick={() => {
setFreeFont(!freeFont);
}}
>
Custom font
</Button>
<Button
ml={0.5}
icon={freeFont ? 'lock-open' : 'lock'}
color={freeFont ? 'good' : 'bad'}
onClick={() => {
setFreeFont(!freeFont);
}}
>
Custom font
</Button>
</Stack>
)}
</Stack.Item>
</LabeledList.Item>
<LabeledList.Item label="Font size" verticalAlign="middle">
<Stack textAlign="center">
<Stack.Item grow>
<Slider
width="100%"
step={1}
stepPixelSize={20}
minValue={8}
maxValue={32}
value={fontSize}
unit="px"
format={(value) => toFixed(value)}
onChange={(e, value) =>
dispatch(updateSettings({ fontSize: value }))
}
/>
</Stack.Item>
</Stack>
</LabeledList.Item>
<LabeledList.Item label="Font size">
<NumberInput
width="4em"
step={1}
stepPixelSize={10}
minValue={8}
maxValue={32}
value={fontSize}
unit="px"
format={(value) => toFixed(value)}
onChange={(value) =>
dispatch(
updateSettings({
fontSize: value,
}),
)
}
/>
</LabeledList.Item>
<LabeledList.Item label="Line height">
<NumberInput
width="4em"
<Slider
width="100%"
step={0.01}
stepPixelSize={2}
minValue={0.8}
maxValue={5}
value={lineHeight}
format={(value) => toFixed(value, 2)}
onDrag={(value) =>
onDrag={(e, value) =>
dispatch(
updateSettings({
lineHeight: value,
@@ -4,11 +4,11 @@ import {
Button,
ColorBox,
Divider,
Flex,
Input,
Section,
Stack,
TextArea,
} from 'tgui/components';
} from 'tgui-core/components';
import { rebuildChat } from '../../chat/actions';
import {
@@ -28,7 +28,7 @@ export const TextHighlightSettings = (props) => {
return (
<Section fill scrollable height="235px">
<Section p={0}>
<Flex direction="column">
<Stack direction="column">
{highlightSettings.map((id, i) => (
<TextHighlightSetting
key={i}
@@ -37,7 +37,7 @@ export const TextHighlightSettings = (props) => {
/>
))}
{highlightSettings.length < MAX_HIGHLIGHT_SETTINGS && (
<Flex.Item>
<Stack.Item>
<Button
color="transparent"
icon="plus"
@@ -47,9 +47,9 @@ export const TextHighlightSettings = (props) => {
>
Add Highlight Setting
</Button>
</Flex.Item>
</Stack.Item>
)}
</Flex>
</Stack>
</Section>
<Divider />
<Box>
@@ -78,9 +78,9 @@ const TextHighlightSetting = (props) => {
matchCase,
} = highlightSettingById[id];
return (
<Flex.Item {...rest}>
<Flex mb={1} color="label" align="baseline">
<Flex.Item grow>
<Stack.Item {...rest}>
<Stack mb={1} color="label" align="baseline">
<Stack.Item grow>
<Button
color="transparent"
icon="times"
@@ -94,8 +94,8 @@ const TextHighlightSetting = (props) => {
>
Delete
</Button>
</Flex.Item>
<Flex.Item>
</Stack.Item>
<Stack.Item>
<Button.Checkbox
checked={highlightBlacklist}
tooltip="If this option is selected, you can blacklist senders not to highlight their messages."
@@ -111,8 +111,8 @@ const TextHighlightSetting = (props) => {
>
Highlight Blacklist
</Button.Checkbox>
</Flex.Item>
<Flex.Item>
</Stack.Item>
<Stack.Item>
<Button.Checkbox
checked={highlightWholeMessage}
tooltip="If this option is selected, the entire message will be highlighted in yellow."
@@ -128,8 +128,8 @@ const TextHighlightSetting = (props) => {
>
Whole Message
</Button.Checkbox>
</Flex.Item>
<Flex.Item>
</Stack.Item>
<Stack.Item>
<Button.Checkbox
checked={matchWord}
tooltipPosition="bottom-start"
@@ -145,8 +145,8 @@ const TextHighlightSetting = (props) => {
>
Exact
</Button.Checkbox>
</Flex.Item>
<Flex.Item>
</Stack.Item>
<Stack.Item>
<Button.Checkbox
tooltip="If this option is selected, the highlight will be case-sensitive."
checked={matchCase}
@@ -161,8 +161,8 @@ const TextHighlightSetting = (props) => {
>
Case
</Button.Checkbox>
</Flex.Item>
<Flex.Item shrink={0}>
</Stack.Item>
<Stack.Item shrink={0}>
<ColorBox mr={1} color={highlightColor} />
<Input
width="5em"
@@ -178,8 +178,8 @@ const TextHighlightSetting = (props) => {
)
}
/>
</Flex.Item>
</Flex>
</Stack.Item>
</Stack>
<TextArea
height="3em"
value={highlightText}
@@ -210,6 +210,6 @@ const TextHighlightSetting = (props) => {
) : (
''
)}
</Flex.Item>
</Stack.Item>
);
};
@@ -5,7 +5,7 @@
*/
import { useDispatch, useSelector } from 'tgui/backend';
import { Section, Stack, Tabs } from 'tgui/components';
import { Section, Stack, Tabs } from 'tgui-core/components';
import { ChatPageSettings } from '../chat';
import { changeSettingsTab } from './actions';
@@ -44,7 +44,7 @@ export const SettingsPanel = (props) => {
</Tabs>
</Section>
</Stack.Item>
<Stack.Item grow={1} basis={0}>
<Stack.Item grow basis={0}>
{activeTab === 'general' && <SettingsGeneral />}
{activeTab === 'limits' && <MessageLimits />}
{activeTab === 'export' && <ExportTab />}
@@ -1,5 +1,3 @@
import { toFixed } from 'common/math';
import { capitalize } from 'common/string';
import { useDispatch, useSelector } from 'tgui/backend';
import {
Button,
@@ -8,7 +6,9 @@ import {
Section,
Slider,
Stack,
} from 'tgui/components';
} from 'tgui-core/components';
import { toFixed } from 'tgui-core/math';
import { capitalize } from 'tgui-core/string';
import { updateSettings } from './actions';
import { selectSettings } from './selectors';
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* @file
*/
import { createUuid } from 'common/uuid';
import { createUuid } from 'tgui-core/uuid';
export const createHighlightSetting = (obj?: Record<string, any>) => ({
id: createUuid(),
+28 -22
View File
@@ -8,8 +8,8 @@
@use '~tgui/styles/colors.scss';
@use '~tgui/styles/base.scss' with (
$color-bg: #202020,
$color-bg-section: color.adjust(#202020, $lightness: -5%),
$color-bg: hsl(0, 0%, 12.5%),
$color-bg-section: color.adjust(hsl(0, 0%, 12.5%), $lightness: -5%),
$color-bg-grad-spread: 0%
);
@@ -24,26 +24,32 @@
@include meta.load-css('~tgui/styles/atomic/text.scss');
// Components
@include meta.load-css('~tgui/styles/components/Button.scss');
@include meta.load-css('~tgui/styles/components/ColorBox.scss');
@include meta.load-css('~tgui/styles/components/Dimmer.scss');
@include meta.load-css('~tgui/styles/components/Divider.scss');
@include meta.load-css('~tgui/styles/components/Dropdown.scss');
@include meta.load-css('~tgui/styles/components/Flex.scss');
@include meta.load-css('~tgui/styles/components/Input.scss');
@include meta.load-css('~tgui/styles/components/Knob.scss');
@include meta.load-css('~tgui/styles/components/LabeledList.scss');
@include meta.load-css('~tgui/styles/components/Modal.scss');
@include meta.load-css('~tgui/styles/components/NoticeBox.scss');
@include meta.load-css('~tgui/styles/components/NumberInput.scss');
@include meta.load-css('~tgui/styles/components/ProgressBar.scss');
@include meta.load-css('~tgui/styles/components/Section.scss');
@include meta.load-css('~tgui/styles/components/Slider.scss');
@include meta.load-css('~tgui/styles/components/Stack.scss');
@include meta.load-css('~tgui/styles/components/Table.scss');
@include meta.load-css('~tgui/styles/components/Tabs.scss');
@include meta.load-css('~tgui/styles/components/TextArea.scss');
@include meta.load-css('~tgui/styles/components/Tooltip.scss');
@include meta.load-css('~tgui-core/styles/components/BlockQuote.scss');
@include meta.load-css('~tgui-core/styles/components/Button.scss');
@include meta.load-css('~tgui-core/styles/components/ColorBox.scss');
@include meta.load-css('~tgui-core/styles/components/Dialog.scss');
@include meta.load-css('~tgui-core/styles/components/Dimmer.scss');
@include meta.load-css('~tgui-core/styles/components/Divider.scss');
@include meta.load-css('~tgui-core/styles/components/Dropdown.scss');
@include meta.load-css('~tgui-core/styles/components/Flex.scss');
@include meta.load-css('~tgui-core/styles/components/Icon.scss');
@include meta.load-css('~tgui-core/styles/components/ImageButton.scss');
@include meta.load-css('~tgui-core/styles/components/Input.scss');
@include meta.load-css('~tgui-core/styles/components/Knob.scss');
@include meta.load-css('~tgui-core/styles/components/LabeledList.scss');
@include meta.load-css('~tgui-core/styles/components/MenuBar.scss');
@include meta.load-css('~tgui-core/styles/components/Modal.scss');
@include meta.load-css('~tgui-core/styles/components/NoticeBox.scss');
@include meta.load-css('~tgui-core/styles/components/NumberInput.scss');
@include meta.load-css('~tgui-core/styles/components/ProgressBar.scss');
@include meta.load-css('~tgui-core/styles/components/RoundGauge.scss');
@include meta.load-css('~tgui-core/styles/components/Section.scss');
@include meta.load-css('~tgui-core/styles/components/Slider.scss');
@include meta.load-css('~tgui-core/styles/components/Stack.scss');
@include meta.load-css('~tgui-core/styles/components/Table.scss');
@include meta.load-css('~tgui-core/styles/components/Tabs.scss');
@include meta.load-css('~tgui-core/styles/components/TextArea.scss');
@include meta.load-css('~tgui-core/styles/components/Tooltip.scss');
// Components specific to tgui-panel
@include meta.load-css('./components/Chat.scss');
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,21 +7,20 @@
@use 'sass:meta';
@use '~tgui/styles/colors.scss' with (
$primary: #ffffff,
$primary: hsl(0, 0%, 100%),
$bg-lightness: -25%,
$fg-lightness: -10%,
$label: #3b3b3b,
$label: hsl(0, 0%, 23.1%),
// Makes button look actually grey due to weird maths.
$grey: #ffffff,
$grey: hsl(0, 0%, 100%),
// Commenting out color maps will adjust all colors based on the lightness
// settings above, but will add extra 10KB to the theme.
// $fg-map-keys: (),
// $bg-map-keys: (),
// $bg-map-keys: (),
);
@use '~tgui/styles/base.scss' with (
$color-fg: #000000,
$color-bg: #eeeeee,
$color-bg-section: #ffffff,
$color-fg: hsl(0, 0%, 0%),
$color-bg: hsl(0, 0%, 93.3%),
$color-bg-grad-spread: 0%
);
@@ -33,38 +32,48 @@
// Components
@include meta.load-css(
'~tgui/styles/components/Tabs.scss',
$with: ('text-color': rgba(0, 0, 0, 0.5), 'color-default': rgba(0, 0, 0, 1))
);
@include meta.load-css('~tgui/styles/components/Section.scss');
@include meta.load-css(
'~tgui/styles/components/Button.scss',
'~tgui-core/styles/components/Tabs',
$with: (
'color-default': #bbbbbb,
'color-disabled': #363636,
'color-selected': #0668b8,
'color-caution': #be6209,
'color-danger': #9a9d00,
'text-color': rgba(0, 0, 0, 0.5),
'color-default': rgba(0, 0, 0, 1),
'tab-color-selected': rgba(0, 0, 0, 0.125),
'tab-color-hovered': rgba(0, 0, 0, 0.075)
)
);
@include meta.load-css(
'~tgui-core/styles/components/Section',
$with: ('background-color': hsl(0, 0%, 100%))
);
@include meta.load-css(
'~tgui-core/styles/components/Button',
$with: (
'color-default': hsl(0, 0%, 73.3%),
'color-disabled': hsl(0, 0%, 21.2%),
'color-selected': hsl(204, 94%, 35.3%),
'color-caution': hsl(28, 91%, 38.2%),
'color-danger': hsl(62, 100%, 30.2%),
'color-transparent-text': rgba(0, 0, 0, 0.5)
)
);
@include meta.load-css(
'~tgui/styles/components/Input.scss',
'~tgui-core/styles/components/Input',
$with: (
'border-color': colors.fg(colors.$label),
'background-color': #ffffff
'background-color': hsl(0, 0%, 90.2%)
)
);
@include meta.load-css('~tgui/styles/components/NumberInput.scss');
@include meta.load-css('~tgui/styles/components/TextArea.scss');
@include meta.load-css('~tgui/styles/components/Knob.scss');
@include meta.load-css('~tgui/styles/components/Slider.scss');
@include meta.load-css('~tgui/styles/components/ProgressBar.scss');
@include meta.load-css('~tgui-core/styles/components/NumberInput');
@include meta.load-css('~tgui-core/styles/components/TextArea');
@include meta.load-css('~tgui-core/styles/components/Knob');
@include meta.load-css('~tgui-core/styles/components/Slider');
@include meta.load-css('~tgui-core/styles/components/ProgressBar');
// Components specific to tgui-panel
@include meta.load-css(
'../components/Chat.scss',
$with: ('text-color': #000000)
$with: ('text-color': hsl(0, 0%, 0%))
);
// Layouts
@@ -16,15 +16,15 @@
@include meta.load-css('~tgui/styles/atomic/color.scss');
// Components
@include meta.load-css('~tgui/styles/components/Tabs.scss');
@include meta.load-css('~tgui/styles/components/Section.scss');
@include meta.load-css('~tgui/styles/components/Button.scss');
@include meta.load-css('~tgui/styles/components/Input.scss');
@include meta.load-css('~tgui/styles/components/NumberInput.scss');
@include meta.load-css('~tgui/styles/components/TextArea.scss');
@include meta.load-css('~tgui/styles/components/Knob.scss');
@include meta.load-css('~tgui/styles/components/Slider.scss');
@include meta.load-css('~tgui/styles/components/ProgressBar.scss');
@include meta.load-css('~tgui-core/styles/components/Tabs.scss');
@include meta.load-css('~tgui-core/styles/components/Section.scss');
@include meta.load-css('~tgui-core/styles/components/Button.scss');
@include meta.load-css('~tgui-core/styles/components/Input.scss');
@include meta.load-css('~tgui-core/styles/components/NumberInput.scss');
@include meta.load-css('~tgui-core/styles/components/TextArea.scss');
@include meta.load-css('~tgui-core/styles/components/Knob.scss');
@include meta.load-css('~tgui-core/styles/components/Slider.scss');
@include meta.load-css('~tgui-core/styles/components/ProgressBar.scss');
// Components specific to tgui-panel
@include meta.load-css(
@@ -7,21 +7,20 @@
@use 'sass:meta';
@use '~tgui/styles/colors.scss' with (
$primary: #ffffff,
$primary: hsl(0, 0%, 100%),
$bg-lightness: -25%,
$fg-lightness: -10%,
$label: #3b3b3b,
$label: hsl(0, 0%, 23.1%),
// Makes button look actually grey due to weird maths.
$grey: #ffffff,
$grey: hsl(0, 0%, 100%),
// Commenting out color maps will adjust all colors based on the lightness
// settings above, but will add extra 10KB to the theme.
// $fg-map-keys: (),
// $bg-map-keys: (),
// $bg-map-keys: (),
);
@use '~tgui/styles/base.scss' with (
$color-fg: #000000,
$color-bg: #eeeeee,
$color-bg-section: #ffffff,
$color-fg: hsl(0, 0%, 0%),
$color-bg: hsl(0, 0%, 93.3%),
$color-bg-grad-spread: 0%
);
@@ -33,38 +32,48 @@
// Components
@include meta.load-css(
'~tgui/styles/components/Tabs.scss',
$with: ('text-color': rgba(0, 0, 0, 0.5), 'color-default': rgba(0, 0, 0, 1))
);
@include meta.load-css('~tgui/styles/components/Section.scss');
@include meta.load-css(
'~tgui/styles/components/Button.scss',
'~tgui-core/styles/components/Tabs',
$with: (
'color-default': #bbbbbb,
'color-disabled': #363636,
'color-selected': #0668b8,
'color-caution': #be6209,
'color-danger': #9a9d00,
'text-color': rgba(0, 0, 0, 0.5),
'color-default': rgba(0, 0, 0, 1),
'tab-color-selected': rgba(0, 0, 0, 0.125),
'tab-color-hovered': rgba(0, 0, 0, 0.075)
)
);
@include meta.load-css(
'~tgui-core/styles/components/Section',
$with: ('background-color': hsl(0, 0%, 100%))
);
@include meta.load-css(
'~tgui-core/styles/components/Button',
$with: (
'color-default': hsl(0, 0%, 73.3%),
'color-disabled': hsl(0, 0%, 21.2%),
'color-selected': hsl(204, 94%, 35.3%),
'color-caution': hsl(28, 91%, 38.2%),
'color-danger': hsl(62, 100%, 30.2%),
'color-transparent-text': rgba(0, 0, 0, 0.5)
)
);
@include meta.load-css(
'~tgui/styles/components/Input.scss',
'~tgui-core/styles/components/Input',
$with: (
'border-color': colors.fg(colors.$label),
'background-color': #ffffff
'background-color': hsl(0, 0%, 90.2%)
)
);
@include meta.load-css('~tgui/styles/components/NumberInput.scss');
@include meta.load-css('~tgui/styles/components/TextArea.scss');
@include meta.load-css('~tgui/styles/components/Knob.scss');
@include meta.load-css('~tgui/styles/components/Slider.scss');
@include meta.load-css('~tgui/styles/components/ProgressBar.scss');
@include meta.load-css('~tgui-core/styles/components/NumberInput');
@include meta.load-css('~tgui-core/styles/components/TextArea');
@include meta.load-css('~tgui-core/styles/components/Knob');
@include meta.load-css('~tgui-core/styles/components/Slider');
@include meta.load-css('~tgui-core/styles/components/ProgressBar');
// Components specific to tgui-panel
@include meta.load-css(
'../components/Chat.scss',
$with: ('text-color': #000000)
$with: ('text-color': hsl(0, 0%, 0%))
);
// Layouts
@@ -11,14 +11,17 @@ const logger = createLogger('telemetry');
const MAX_CONNECTIONS_STORED = 10;
const connectionsMatch = (a, b) =>
type Client = { ckey: string; address: string; computer_id: string };
type Telemetry = { limits: { connections: number }[]; connections: Client[] };
const connectionsMatch = (a: Client, b: Client) =>
a.ckey === b.ckey &&
a.address === b.address &&
a.computer_id === b.computer_id;
export const telemetryMiddleware = (store) => {
let telemetry;
let wasRequestedWithPayload;
let telemetry: Telemetry;
let wasRequestedWithPayload: Telemetry | null;
return (next) => (action) => {
const { type, payload } = action;
// Handle telemetry requests
+3 -3
View File
@@ -1,16 +1,16 @@
{
"private": true,
"name": "tgui-polyfill",
"version": "5.0.1",
"version": "5.0.3",
"scripts": {
"tgui-polyfill:build": "terser 1-misc.js -f ascii_only,comments=false -o ../../public/tgui-polyfill.min.js"
},
"dependencies": {
"core-js": "^3.36.1",
"core-js": "^3.39.0",
"regenerator-runtime": "^0.14.1",
"unfetch": "^5.0.0"
},
"devDependencies": {
"terser": "^5.30.2"
"terser": "^5.36.0"
}
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { isEscape, KEY } from 'common/keys';
import { clamp } from 'common/math';
import { BooleanLike } from 'common/react';
import { Component, createRef, RefObject } from 'react';
import { dragStartHandler } from 'tgui/drag';
import {
removeAllSkiplines,
sanitizeMultiline,
} from 'tgui/interfaces/TextInputModal';
import { isEscape, KEY } from 'tgui-core/keys';
import { clamp } from 'tgui-core/math';
import { BooleanLike } from 'tgui-core/react';
import { Channel, ChannelIterator } from './ChannelIterator';
import { ChatHistory } from './ChatHistory';
+5 -4
View File
@@ -3,12 +3,13 @@
"name": "tgui-say",
"version": "1.0.0",
"dependencies": {
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"common": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tgui": "workspace:*",
"tgui-core": "^1.7.5",
"tgui-polyfill": "workspace:*"
}
}
+6 -9
View File
@@ -13,16 +13,13 @@
font-weight: bold;
justify-content: center;
padding: 0;
width: 1rem;
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;
background-color: color.adjust(
colors.$button,
$lightness: 10%,
$space: hsl
);
}
}
+27 -27
View File
@@ -1,36 +1,36 @@
@use 'sass:map';
$background: #131313;
$button: #1f1f1f;
$lightMode: #ffffff;
$lightBorder: #bbbbbb;
$lightHover: #eaeaea;
$background: hsl(0, 0%, 7%);
$button: hsl(0, 0%, 12%);
$lightMode: hsl(0, 0%, 100%);
$lightBorder: hsl(0, 0%, 73%);
$lightHover: hsl(0, 0%, 92%);
$scrollbar-color-multiplier: 1 !default;
$_channel_map: (
// Radio (EVA, Cas, Int use default color)
'010': #1e90ff,
'Cmd': #57b8f0,
'Eng': #fcdf03,
'Dept': #1ecc43,
'ERT': #5c5c8a,
'Med': #57f09e,
'Sci': #c68cfa,
'AI': #d65d95,
'Sec': #dd3535,
'Merc': #8f4a4b,
'Sup': #b88646,
'Srv': #6ca729,
'Rai': #8f4a4b,
'ITV': #8a8a8a,
'010': hsl(210, 100%, 56%),
'Cmd': hsl(202, 84%, 64%),
'Eng': hsl(53, 98%, 50%),
'Dept': hsl(133, 74%, 46%),
'ERT': hsl(240, 20%, 45%),
'Med': hsl(148, 84%, 64%),
'Sci': hsl(272, 92%, 76%),
'AI': hsl(332, 60%, 60%),
'Sec': hsl(0, 71%, 54%),
'Merc': hsl(359, 32%, 43%),
'Sup': hsl(34, 45%, 50%),
'Srv': hsl(88, 61%, 41%),
'Rai': hsl(359, 32%, 43%),
'ITV': hsl(0, 0%, 54%),
// Modes
'LOOC': #3a9696,
'Me': #485fce,
'OOC': #cca300,
'Radio': #1ecc43,
'Say': #a4bad6,
'Whis': #7c7fd9,
'Subtle': #c52076
'LOOC': hsl(180, 44%, 41%),
'Me': hsl(230, 58%, 55%),
'OOC': hsl(48, 100%, 40%),
'Radio': hsl(133, 74%, 46%),
'Say': hsl(214, 38%, 74%),
'Whis': hsl(238, 55%, 67%),
'Subtle': hsl(329, 72%, 45%)
);
$channel_keys: map.keys($_channel_map) !default;
@@ -38,7 +38,7 @@ $channel_keys: map.keys($_channel_map) !default;
$channel-map: ();
@each $channel in $channel_keys {
$channel-map: map-merge(
$channel-map: map.merge(
$channel-map,
(
$channel: map.get($_channel_map, $channel),
+8 -8
View File
@@ -7,7 +7,7 @@
// Atomic styles
@include meta.load-css('~tgui/styles/atomic/text.scss');
// External styles
@include meta.load-css('~tgui/styles/components/TextArea.scss');
@include meta.load-css('~tgui-core/styles/components/TextArea');
// Local styles
@include meta.load-css('./button.scss');
@include meta.load-css('./content.scss');
@@ -26,14 +26,14 @@
}
@each $channel, $color in colors.$channel-map {
$darkened: darken($color, 20%);
$darkened: color.adjust($color, $lightness: -20%, $space: hsl);
.button-#{$channel} {
border-color: darken($color, 10%);
border-color: color.adjust($color, $lightness: -10%, $space: hsl);
color: $color;
&:hover {
border-color: lighten($color, 10%);
color: lighten($color, 5%);
border-color: color.adjust($color, $lightness: 10%, $space: hsl);
color: color.adjust($color, $lightness: 5%, $space: hsl);
}
}
@@ -50,11 +50,11 @@
animation: gradient 10s linear infinite;
background: linear-gradient(
to right,
darken($color, 35%),
color.adjust($color, $lightness: -35%, $space: hsl),
$color,
lighten($color, 10%),
color.adjust($color, $lightness: 10%, $space: hsl),
$color,
darken($color, 35%)
color.adjust($color, $lightness: -35%, $space: hsl)
);
background-position: 0% 0%;
background-size: 500% auto;
+1 -1
View File
@@ -1,4 +1,4 @@
import { debounce, throttle } from 'common/timer';
import { debounce, throttle } from 'tgui-core/timer';
const SECONDS = 1000;
+1 -3
View File
@@ -4,9 +4,7 @@
* @license MIT
*/
import { Dispatch } from 'common/redux';
import { Action, AnyAction, Middleware } from '../common/redux';
import { Action, AnyAction, Dispatch, Middleware } from 'common/redux';
const EXCLUDED_PATTERNS = [/v4shim/i];
const loadedMappings: Record<string, string> = {};
+1 -1
View File
@@ -13,9 +13,9 @@
import { perf } from 'common/perf';
import { createAction } from 'common/redux';
import { globalEvents } from 'tgui-core/events';
import { setupDrag } from './drag';
import { globalEvents } from './events';
import { focusMap } from './focus';
import { createLogger } from './logging';
import { resumeRenderer, suspendRenderer } from './renderer';
@@ -1,186 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp, toFixed } from 'common/math';
import { Component, createRef } from 'react';
const isSafeNumber = (value: number) => {
// prettier-ignore
return typeof value === 'number'
&& Number.isFinite(value)
&& !Number.isNaN(value);
};
export type AnimatedNumberProps = {
/**
* The target value to approach.
*/
value: number;
/**
* If provided, the initial value displayed. By default, the same as `value`.
* If `initial` and `value` are different, the component immediately starts
* animating.
*/
initial?: number;
/**
* If provided, a function that formats the inner string. By default,
* attempts to match the numeric precision of `value`.
*/
format?: (value: number) => string;
};
/**
* Animated numbers are animated at roughly 60 frames per second.
*/
const SIXTY_HZ = 1_000.0 / 60.0;
/**
* The exponential moving average coefficient. Larger values result in a faster
* convergence.
*/
const Q = 0.8333;
/**
* A small number.
*/
const EPSILON = 10e-4;
/**
* An animated number label. Shows a number, formatted with an optionally
* provided function, and animates it towards its target value.
*/
export class AnimatedNumber extends Component<AnimatedNumberProps> {
/**
* The inner `<span/>` being updated sixty times per second.
*/
ref = createRef<HTMLSpanElement>();
/**
* The interval being used to update the inner span.
*/
interval?: NodeJS.Timeout;
/**
* The current value. This values approaches the target value.
*/
currentValue: number = 0;
constructor(props: AnimatedNumberProps) {
super(props);
const { initial, value } = props;
if (initial !== undefined && isSafeNumber(initial)) {
this.currentValue = initial;
} else if (isSafeNumber(value)) {
this.currentValue = value;
}
}
componentDidMount() {
if (this.currentValue !== this.props.value) {
this.startTicking();
}
}
componentWillUnmount() {
// Stop animating when the component is unmounted.
this.stopTicking();
}
shouldComponentUpdate(newProps: AnimatedNumberProps) {
if (newProps.value !== this.props.value) {
// The target value has been adjusted; start animating if we aren't
// already.
this.startTicking();
}
return false;
}
/**
* Starts animating the inner span. If the inner span is already animating,
* this is a no-op.
*/
startTicking() {
if (this.interval !== undefined) {
// We're already ticking; do nothing.
return;
}
this.interval = setInterval(() => this.tick(), SIXTY_HZ);
}
/**
* Stops animating the inner span.
*/
stopTicking() {
if (this.interval === undefined) {
// We're not ticking; do nothing.
return;
}
clearInterval(this.interval);
this.interval = undefined;
}
/**
* Steps forward one frame.
*/
tick() {
const { currentValue } = this;
const { value } = this.props;
if (isSafeNumber(value)) {
// Converge towards the value.
this.currentValue = currentValue * Q + value * (1 - Q);
} else {
// If the value is unsafe, we're never going to converge, so stop ticking.
this.stopTicking();
}
if (
Math.abs(value - this.currentValue) < Math.max(EPSILON, EPSILON * value)
) {
// We're about as close as we're going to get--snap to the value and
// stop ticking.
this.currentValue = value;
this.stopTicking();
}
if (this.ref.current) {
this.ref.current.textContent = this.getText();
}
}
/**
* Gets the inner text of the span.
*/
getText() {
const { props, currentValue } = this;
const { format, value } = props;
if (!isSafeNumber(value)) {
return String(value);
}
if (format) {
return format(this.currentValue);
}
const fraction = String(value).split('.')[1];
const precision = fraction ? fraction.length : 0;
return toFixed(currentValue, clamp(precision, 0, 8));
}
render() {
return <span ref={this.ref}>{this.getText()}</span>;
}
}
@@ -1,23 +0,0 @@
import { PropsWithChildren, useEffect, useRef } from 'react';
/** Used to force the window to steal focus on load. Children optional */
export function Autofocus(props: PropsWithChildren) {
const { children } = props;
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const timer = setTimeout(() => {
ref.current?.focus();
}, 1);
return () => {
clearTimeout(timer);
};
}, []);
return (
<div ref={ref} tabIndex={-1}>
{children}
</div>
);
}
-272
View File
@@ -1,272 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { BooleanLike, classes } from 'common/react';
import {
createElement,
KeyboardEventHandler,
MouseEventHandler,
ReactNode,
UIEventHandler,
} from 'react';
import { CSS_COLORS } from '../constants';
import { logger } from '../logging';
type BooleanProps = Partial<Record<keyof typeof booleanStyleMap, boolean>>;
type StringProps = Partial<
Record<keyof typeof stringStyleMap, string | BooleanLike>
>;
export type EventHandlers = Partial<{
onClick: MouseEventHandler<HTMLDivElement>;
onContextMenu: MouseEventHandler<HTMLDivElement>;
onDoubleClick: MouseEventHandler<HTMLDivElement>;
onKeyDown: KeyboardEventHandler<HTMLDivElement>;
onKeyUp: KeyboardEventHandler<HTMLDivElement>;
onMouseDown: MouseEventHandler<HTMLDivElement>;
onMouseMove: MouseEventHandler<HTMLDivElement>;
onMouseOver: MouseEventHandler<HTMLDivElement>;
onMouseUp: MouseEventHandler<HTMLDivElement>;
onScroll: UIEventHandler<HTMLDivElement>;
}>;
export type BoxProps = Partial<{
as: string;
children: ReactNode;
className: string | BooleanLike;
style: Partial<CSSStyleDeclaration>;
}> &
BooleanProps &
StringProps &
EventHandlers;
// Don't you dare put this elsewhere
type DangerDoNotUse = {
dangerouslySetInnerHTML?: {
__html: any;
};
};
/**
* Coverts our rem-like spacing unit into a CSS unit.
*/
export const unit = (value: unknown) => {
if (typeof value === 'string') {
// Transparently convert pixels into rem units
if (value.endsWith('px')) {
return parseFloat(value) / 12 + 'rem';
}
return value;
}
if (typeof value === 'number') {
return value + 'rem';
}
};
/**
* Same as `unit`, but half the size for integers numbers.
*/
export const halfUnit = (value: unknown) => {
if (typeof value === 'string') {
return unit(value);
}
if (typeof value === 'number') {
return unit(value * 0.5);
}
};
const isColorCode = (str: unknown) => !isColorClass(str);
const isColorClass = (str: unknown): boolean => {
return typeof str === 'string' && CSS_COLORS.includes(str as any);
};
const mapRawPropTo = (attrName) => (style, value) => {
if (typeof value === 'number' || typeof value === 'string') {
style[attrName] = value;
}
};
const mapUnitPropTo = (attrName, unit) => (style, value) => {
if (typeof value === 'number' || typeof value === 'string') {
style[attrName] = unit(value);
}
};
const mapBooleanPropTo = (attrName, attrValue) => (style, value) => {
if (value) {
style[attrName] = attrValue;
}
};
const mapDirectionalUnitPropTo = (attrName, unit, dirs) => (style, value) => {
if (typeof value === 'number' || typeof value === 'string') {
for (let i = 0; i < dirs.length; i++) {
style[attrName + '-' + dirs[i]] = unit(value);
}
}
};
const mapColorPropTo = (attrName) => (style, value) => {
if (isColorCode(value)) {
style[attrName] = value;
}
};
// String / number props
const stringStyleMap = {
align: mapRawPropTo('textAlign'),
bottom: mapUnitPropTo('bottom', unit),
colSpan: mapRawPropTo('colSpan'),
fontFamily: mapRawPropTo('fontFamily'),
fontSize: mapUnitPropTo('fontSize', unit),
fontWeight: mapRawPropTo('fontWeight'),
height: mapUnitPropTo('height', unit),
left: mapUnitPropTo('left', unit),
maxHeight: mapUnitPropTo('maxHeight', unit),
maxWidth: mapUnitPropTo('maxWidth', unit),
minHeight: mapUnitPropTo('minHeight', unit),
minWidth: mapUnitPropTo('minWidth', unit),
opacity: mapRawPropTo('opacity'),
overflow: mapRawPropTo('overflow'),
overflowX: mapRawPropTo('overflowX'),
overflowY: mapRawPropTo('overflowY'),
position: mapRawPropTo('position'),
right: mapUnitPropTo('right', unit),
textAlign: mapRawPropTo('textAlign'),
top: mapUnitPropTo('top', unit),
verticalAlign: mapRawPropTo('verticalAlign'),
width: mapUnitPropTo('width', unit),
lineHeight: (style, value) => {
if (typeof value === 'number') {
style['lineHeight'] = value;
} else if (typeof value === 'string') {
style['lineHeight'] = unit(value);
}
},
// Margin
m: mapDirectionalUnitPropTo('margin', halfUnit, [
'Top',
'Bottom',
'Left',
'Right',
]),
mb: mapUnitPropTo('marginBottom', halfUnit),
ml: mapUnitPropTo('marginLeft', halfUnit),
mr: mapUnitPropTo('marginRight', halfUnit),
mt: mapUnitPropTo('marginTop', halfUnit),
mx: mapDirectionalUnitPropTo('margin', halfUnit, ['Left', 'Right']),
my: mapDirectionalUnitPropTo('margin', halfUnit, ['Top', 'Bottom']),
// Padding
p: mapDirectionalUnitPropTo('padding', halfUnit, [
'Top',
'Bottom',
'Left',
'Right',
]),
pb: mapUnitPropTo('paddingBottom', halfUnit),
pl: mapUnitPropTo('paddingLeft', halfUnit),
pr: mapUnitPropTo('paddingRight', halfUnit),
pt: mapUnitPropTo('paddingTop', halfUnit),
px: mapDirectionalUnitPropTo('padding', halfUnit, ['Left', 'Right']),
py: mapDirectionalUnitPropTo('padding', halfUnit, ['Top', 'Bottom']),
// Color props
color: mapColorPropTo('color'),
textColor: mapColorPropTo('color'),
backgroundColor: mapColorPropTo('backgroundColor'),
// VOREStation Addition Start
// Flex props
flexGrow: mapRawPropTo('flex-grow'),
flexWrap: mapRawPropTo('flex-wrap'),
flexBasis: mapRawPropTo('flex-basis'),
flex: mapRawPropTo('flex'),
// VOREStation Addition End
} as const;
// Boolean props
const booleanStyleMap = {
bold: mapBooleanPropTo('fontWeight', 'bold'),
fillPositionedParent: (style, value) => {
if (value) {
style['position'] = 'absolute';
style['top'] = 0;
style['bottom'] = 0;
style['left'] = 0;
style['right'] = 0;
}
},
inline: mapBooleanPropTo('display', 'inline-block'),
italic: mapBooleanPropTo('fontStyle', 'italic'),
underline: mapBooleanPropTo('textDecorationLine', 'underline'), // Vorestation Add
nowrap: mapBooleanPropTo('whiteSpace', 'nowrap'),
preserveWhitespace: mapBooleanPropTo('whiteSpace', 'pre-wrap'),
} as const;
export const computeBoxProps = (props) => {
const computedProps: Record<string, any> = {};
const computedStyles: Record<string, string | number> = {};
// Compute props
for (let propName of Object.keys(props)) {
if (propName === 'style') {
continue;
}
const propValue = props[propName];
const mapPropToStyle =
stringStyleMap[propName] || booleanStyleMap[propName];
if (mapPropToStyle) {
mapPropToStyle(computedStyles, propValue);
} else {
computedProps[propName] = propValue;
}
}
// Merge computed styles and any directly provided styles
computedProps.style = { ...computedStyles, ...props.style };
return computedProps;
};
export const computeBoxClassName = (props: BoxProps) => {
const color = props.textColor || props.color;
const backgroundColor = props.backgroundColor;
return classes([
isColorClass(color) && 'color-' + color,
isColorClass(backgroundColor) && 'color-bg-' + backgroundColor,
]);
};
export const Box = (props: BoxProps & DangerDoNotUse) => {
const { as = 'div', className, children, ...rest } = props;
// Compute class name and styles
const computedClassName = className
? `${className} ${computeBoxClassName(rest)}`
: computeBoxClassName(rest);
const computedProps = computeBoxProps(rest);
if (as === 'img') {
logger.error(
'Box component cannot be used as an image. Use Image component instead.',
);
}
// Render the component
return createElement(
typeof as === 'string' ? as : 'div',
{
...computedProps,
className: computedClassName,
},
children,
);
};
-429
View File
@@ -1,429 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { Placement } from '@popperjs/core';
import { isEscape, KEY } from 'common/keys';
import { BooleanLike, classes } from 'common/react';
import {
ChangeEvent,
createRef,
MouseEvent,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { Box, BoxProps, computeBoxClassName, computeBoxProps } from './Box';
import { Icon } from './Icon';
import { Tooltip } from './Tooltip';
/**
* Getting ellipses to work requires that you use:
* 1. A string rather than a node
* 2. A fixed width here or in a parent
* 3. Children prop rather than content
*/
type EllipsisUnion =
| {
ellipsis: true;
children: string;
/** @deprecated use children instead */
content?: never;
}
| Partial<{
ellipsis: undefined;
children: ReactNode;
/** @deprecated use children instead */
content: ReactNode;
}>;
type Props = Partial<{
captureKeys: boolean;
circular: boolean;
compact: boolean;
disabled: BooleanLike;
fluid: boolean;
icon: string | false;
iconColor: string;
iconPosition: string;
iconSize: number; // VOREStation Addition
iconRotation: number;
iconSpin: BooleanLike;
onClick: (e: any) => void;
selected: BooleanLike;
tooltip: ReactNode;
tooltipPosition: Placement;
verticalAlignContent: string;
}> &
EllipsisUnion &
BoxProps;
/** Clickable button. Comes with variants. Read more in the documentation. */
export const Button = (props: Props) => {
const {
captureKeys = true,
children,
circular,
className,
color,
compact,
content,
disabled,
ellipsis,
fluid,
icon,
iconColor,
iconPosition,
iconSize, // VOREStation Addition
iconRotation,
iconSpin,
onClick,
selected,
tooltip,
tooltipPosition,
verticalAlignContent,
...rest
} = props;
const toDisplay: ReactNode = content || children;
const ref = useRef(null);
function handleButtonClick(event) {
if (!disabled && onClick) {
onClick(event);
if (ref?.current) {
(ref.current as HTMLElement).blur();
}
}
}
let buttonContent = (
<div
ref={ref}
className={classes([
'Button',
fluid && 'Button--fluid',
disabled && 'Button--disabled',
selected && 'Button--selected',
!!toDisplay && 'Button--hasContent',
circular && 'Button--circular',
compact && 'Button--compact',
iconPosition && 'Button--iconPosition--' + iconPosition,
verticalAlignContent && 'Button--flex',
verticalAlignContent && fluid && 'Button--flex--fluid',
verticalAlignContent &&
'Button--verticalAlignContent--' + verticalAlignContent,
color && typeof color === 'string'
? 'Button--color--' + color
: 'Button--color--default',
className,
computeBoxClassName(rest),
])}
tabIndex={!disabled ? 0 : undefined}
onClick={(event) => {
handleButtonClick(event);
}}
onKeyDown={(event) => {
if (!captureKeys) {
return;
}
// Simulate a click when pressing space or enter.
if (event.key === KEY.Space || event.key === KEY.Enter) {
event.preventDefault();
if (!disabled && onClick) {
onClick(event);
}
return;
}
// Refocus layout on pressing escape.
if (isEscape(event.key)) {
event.preventDefault();
}
}}
{...computeBoxProps(rest)}
>
<div className="Button__content">
{icon && iconPosition !== 'right' && (
<Icon
name={icon}
color={iconColor}
rotation={iconRotation}
spin={iconSpin}
fontSize={iconSize} // VOREStation Addition
/>
)}
{!ellipsis ? (
toDisplay
) : (
<span
className={classes([
'Button--ellipsis',
icon && 'Button__textMargin',
])}
>
{toDisplay}
</span>
)}
{icon && iconPosition === 'right' && (
<Icon
name={icon}
color={iconColor}
rotation={iconRotation}
spin={iconSpin}
fontSize={iconSize} // VOREStation Addition
/>
)}
</div>
</div>
);
if (tooltip) {
buttonContent = (
<Tooltip content={tooltip} position={tooltipPosition as Placement}>
{buttonContent}
</Tooltip>
);
}
return buttonContent;
};
type CheckProps = Partial<{
checked: BooleanLike;
}> &
Props;
/** Visually toggles between checked and unchecked states. */
export const ButtonCheckbox = (props: CheckProps) => {
const { checked, ...rest } = props;
return (
<Button
color="transparent"
icon={checked ? 'check-square-o' : 'square-o'}
selected={checked}
{...rest}
/>
);
};
Button.Checkbox = ButtonCheckbox;
type ConfirmProps = Partial<{
confirmColor: string;
confirmContent: ReactNode;
confirmIcon: string;
}> &
Props;
/** Requires user confirmation before triggering its action. */
const ButtonConfirm = (props: ConfirmProps) => {
const {
children,
color,
confirmColor = 'bad',
confirmContent = 'Confirm?',
confirmIcon,
ellipsis = true,
icon,
onClick,
...rest
} = props;
const [clickedOnce, setClickedOnce] = useState(false);
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
if (!clickedOnce) {
setClickedOnce(true);
return;
}
onClick?.(event);
setClickedOnce(false);
};
return (
<Button
icon={clickedOnce ? confirmIcon : icon}
color={clickedOnce ? confirmColor : color}
onClick={handleClick}
{...rest}
>
{clickedOnce ? confirmContent : children}
</Button>
);
};
Button.Confirm = ButtonConfirm;
type InputProps = Partial<{
currentValue: string;
defaultValue: string;
fluid: boolean;
maxLength: number;
onCommit: (e: any, value: string) => void;
placeholder: string;
}> &
Props;
/** Accepts and handles user input. */
const ButtonInput = (props: InputProps) => {
const {
children,
color = 'default',
content,
currentValue,
defaultValue,
disabled,
fluid,
icon,
iconRotation,
iconSpin,
maxLength,
onCommit = () => null,
placeholder,
tooltip,
tooltipPosition,
...rest
} = props;
const [inInput, setInInput] = useState(false);
const inputRef = createRef<HTMLInputElement>();
const toDisplay = content || children;
const commitResult = (e) => {
const input = inputRef.current;
if (!input) return;
const hasValue = input.value !== '';
if (hasValue) {
onCommit(e, input.value);
} else {
if (defaultValue) {
onCommit(e, defaultValue);
}
}
};
useEffect(() => {
const input = inputRef.current;
if (!input) return;
if (inInput) {
input.value = currentValue || '';
try {
input.focus();
input.select();
} catch {}
}
}, [inInput, currentValue]);
let buttonContent = (
<Box
className={classes([
'Button',
fluid && 'Button--fluid',
'Button--color--' + color,
])}
{...rest}
onClick={() => setInInput(true)}
>
{icon && <Icon name={icon} rotation={iconRotation} spin={iconSpin} />}
<div>{toDisplay}</div>
<input
disabled={!!disabled}
ref={inputRef}
className="NumberInput__input"
style={{
display: !inInput ? 'none' : '',
textAlign: 'left',
}}
onBlur={(event) => {
if (!inInput) {
return;
}
setInInput(false);
commitResult(event);
}}
onKeyDown={(event) => {
if (event.key === KEY.Enter) {
setInInput(false);
commitResult(event);
return;
}
if (isEscape(event.key)) {
setInInput(false);
}
}}
/>
</Box>
);
if (tooltip) {
buttonContent = (
<Tooltip content={tooltip} position={tooltipPosition as Placement}>
{buttonContent}
</Tooltip>
);
}
return buttonContent;
};
Button.Input = ButtonInput;
type FileProps = {
accept: string;
multiple?: boolean;
onSelectFiles: (files: string | string[]) => void;
} & Props;
/** Accepts file input */
function ButtonFile(props: FileProps) {
const { accept, multiple, onSelectFiles, ...rest } = props;
const inputRef = useRef<HTMLInputElement>(null);
async function read(files: FileList) {
const promises = Array.from(files).map((file) => {
const reader = new FileReader();
return new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsText(file);
});
});
return await Promise.all(promises);
}
async function handleChange(event: ChangeEvent<HTMLInputElement>) {
const files = event.target.files;
if (files?.length) {
const readFiles = await read(files);
onSelectFiles(multiple ? readFiles : readFiles[0]);
}
}
return (
<>
<Button onClick={() => inputRef.current?.click()} {...rest} />
<input
hidden
type="file"
ref={inputRef}
accept={accept}
multiple={multiple}
onChange={handleChange}
/>
</>
);
}
Button.File = ButtonFile;
-160
View File
@@ -1,160 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { map, zip } from 'common/collections';
import { Component, createRef, RefObject } from 'react';
import { Box, BoxProps } from './Box';
type Props = {
data: number[][];
} & Partial<{
fillColor: string;
rangeX: [number, number];
rangeY: [number, number];
strokeColor: string;
strokeWidth: number;
}> &
BoxProps;
type State = {
viewBox: [number, number];
};
type Point = number[];
type Range = [number, number];
const normalizeData = (
data: Point[],
scale: number[],
rangeX?: Range,
rangeY?: Range,
) => {
if (data.length === 0) {
return [];
}
const min = map(zip(...data), (p) => Math.min(...p));
const max = map(zip(...data), (p) => Math.max(...p));
if (rangeX !== undefined) {
min[0] = rangeX[0];
max[0] = rangeX[1];
}
if (rangeY !== undefined) {
min[1] = rangeY[0];
max[1] = rangeY[1];
}
const normalized = map(data, (point) =>
map(
zip(point, min, max, scale),
([value, min, max, scale]) => ((value - min) / (max - min)) * scale,
),
);
return normalized;
};
const dataToPolylinePoints = (data) => {
let points = '';
for (let i = 0; i < data.length; i++) {
const point = data[i];
points += point[0] + ',' + point[1] + ' ';
}
return points;
};
class LineChart extends Component<Props> {
ref: RefObject<HTMLDivElement>;
state: State;
constructor(props: Props) {
super(props);
this.ref = createRef();
this.state = {
// Initial guess
viewBox: [600, 200],
};
this.handleResize = this.handleResize.bind(this);
}
componentDidMount() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
handleResize = () => {
const element = this.ref.current;
if (!element) {
return;
}
this.setState({
viewBox: [element.offsetWidth, element.offsetHeight],
});
};
render() {
const {
data = [],
rangeX,
rangeY,
fillColor = 'none',
strokeColor = '#ffffff',
strokeWidth = 2,
...rest
} = this.props;
const { viewBox } = this.state;
const normalized = normalizeData(data, viewBox, rangeX, rangeY);
// Push data outside viewBox and form a fillable polygon
if (normalized.length > 0) {
const first = normalized[0];
const last = normalized[normalized.length - 1];
normalized.push([viewBox[0] + strokeWidth, last[1]]);
normalized.push([viewBox[0] + strokeWidth, -strokeWidth]);
normalized.push([-strokeWidth, -strokeWidth]);
normalized.push([-strokeWidth, first[1]]);
}
const points = dataToPolylinePoints(normalized);
const divProps = { ...rest, className: '', ref: this.ref };
return (
<Box position="relative" {...rest}>
<Box {...divProps}>
<svg
viewBox={`0 0 ${viewBox[0]} ${viewBox[1]}`}
preserveAspectRatio="none"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<polyline
transform={`scale(1, -1) translate(0, -${viewBox[1]})`}
fill={fillColor}
stroke={strokeColor}
strokeWidth={strokeWidth}
points={points}
/>
</svg>
</Box>
</Box>
);
}
}
export const Chart = {
Line: LineChart,
};
@@ -1,54 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { ReactNode, useState } from 'react';
import { Box, BoxProps } from './Box';
import { Button } from './Button';
type Props = Partial<{
buttons: ReactNode;
open: boolean;
title: ReactNode;
icon: string;
child_mt: number; // Vorestation Add
}> &
BoxProps;
export function Collapsible(props: Props) {
const {
children,
color,
title,
buttons,
icon,
child_mt = 1,
...rest
} = props;
const [open, setOpen] = useState(props.open);
return (
<Box mb={1}>
<div className="Table">
<div className="Table__cell">
<Button
fluid
color={color}
icon={icon ? icon : open ? 'chevron-down' : 'chevron-right'}
onClick={() => setOpen(!open)}
{...rest}
>
{title}
</Button>
</div>
{buttons && (
<div className="Table__cell Table__cell--collapsing">{buttons}</div>
)}
</div>
{open && <Box mt={child_mt}>{children}</Box>}
</Box>
);
}
@@ -1,30 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { ReactNode } from 'react';
import { BoxProps, computeBoxClassName, computeBoxProps } from './Box';
type Props = {
content?: ReactNode;
} & BoxProps;
export function ColorBox(props: Props) {
const { content, children, className, ...rest } = props;
rest.color = content ? null : 'default';
rest.backgroundColor = props.color || 'default';
return (
<div
className={classes(['ColorBox', className, computeBoxClassName(rest)])}
{...computeBoxProps(rest)}
>
{content}
</div>
);
}
-85
View File
@@ -1,85 +0,0 @@
/**
* @file
* @copyright 2022 raffclar
* @license MIT
*/
import { Box } from './Box';
import { Button } from './Button';
type DialogProps = {
title: any;
onClose: () => void;
children: any;
width?: string;
height?: string;
};
export const Dialog = (props: DialogProps) => {
const { title, onClose, children, width, height } = props;
return (
<div className="Dialog">
<Box className="Dialog__content" width={width || '370px'} height={height}>
<div className="Dialog__header">
<div className="Dialog__title">{title}</div>
<Box mr={2}>
<Button
mr="-3px"
width="26px"
lineHeight="22px"
textAlign="center"
color="transparent"
icon="window-close-o"
tooltip="Close"
tooltipPosition="bottom-start"
onClick={onClose}
/>
</Box>
</div>
{children}
</Box>
</div>
);
};
type DialogButtonProps = {
onClick: () => void;
children: any;
};
const DialogButton = (props: DialogButtonProps) => {
const { onClick, children } = props;
return (
<Button
onClick={onClick}
className="Dialog__button"
verticalAlignContent="middle"
>
{children}
</Button>
);
};
Dialog.Button = DialogButton;
type UnsavedChangesDialogProps = {
documentName: string;
onSave: () => void;
onDiscard: () => void;
onClose: () => void;
};
export const UnsavedChangesDialog = (props: UnsavedChangesDialogProps) => {
const { documentName, onSave, onDiscard, onClose } = props;
return (
<Dialog title="Notepad" onClose={onClose}>
<div className="Dialog__body">
Do you want to save changes to {documentName}?
</div>
<div className="Dialog__footer">
<DialogButton onClick={onSave}>Save</DialogButton>
<DialogButton onClick={onDiscard}>Don&apos;t Save</DialogButton>
<DialogButton onClick={onClose}>Cancel</DialogButton>
</div>
</Dialog>
);
};
-19
View File
@@ -1,19 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
import { Box, BoxProps } from './Box';
export function Dimmer(props: BoxProps) {
const { className, children, ...rest } = props;
return (
<Box className={classes(['Dimmer', className])} {...rest}>
<div className="Dimmer__inner">{children}</div>
</Box>
);
}
-26
View File
@@ -1,26 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { classes } from 'common/react';
type Props = Partial<{
hidden: boolean;
vertical: boolean;
}>;
export function Divider(props: Props) {
const { hidden, vertical } = props;
return (
<div
className={classes([
'Divider',
hidden && 'Divider--hidden',
vertical ? 'Divider--vertical' : 'Divider--horizontal',
])}
/>
);
}
@@ -1,284 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
import { clamp } from 'common/math';
import { Component, createRef } from 'react';
import { AnimatedNumber } from './AnimatedNumber';
const DEFAULT_UPDATE_RATE = 400;
/**
* Reduces screen offset to a single number based on the matrix provided.
*/
const getScalarScreenOffset = (e, matrix) => {
return e.screenX * matrix[0] + e.screenY * matrix[1];
};
export class DraggableControl extends Component {
constructor(props) {
super(props);
this.inputRef = createRef();
this.state = {
value: props.value,
dragging: false,
editing: false,
internalValue: null,
origin: null,
suppressingFlicker: false,
};
// Suppresses flickering while the value propagates through the backend
this.flickerTimer = null;
this.suppressFlicker = () => {
const { suppressFlicker } = this.props;
if (suppressFlicker > 0) {
this.setState({
suppressingFlicker: true,
});
clearTimeout(this.flickerTimer);
this.flickerTimer = setTimeout(() => {
this.setState({
suppressingFlicker: false,
});
}, suppressFlicker);
}
};
this.handleDragStart = (e) => {
const { value, dragMatrix } = this.props;
const { editing } = this.state;
if (editing) {
return;
}
document.body.style['pointer-events'] = 'none';
this.ref = e.target;
this.setState({
dragging: false,
origin: getScalarScreenOffset(e, dragMatrix),
value,
internalValue: value,
});
this.timer = setTimeout(() => {
this.setState({
dragging: true,
});
}, 250);
this.dragInterval = setInterval(() => {
const { dragging, value } = this.state;
const { onDrag } = this.props;
if (dragging && onDrag) {
onDrag(e, value);
}
}, this.props.updateRate || DEFAULT_UPDATE_RATE);
document.addEventListener('mousemove', this.handleDragMove);
document.addEventListener('mouseup', this.handleDragEnd);
};
this.handleDragMove = (e) => {
// prettier-ignore
const {
minValue,
maxValue,
step,
stepPixelSize,
dragMatrix,
} = this.props;
this.setState((prevState) => {
const state = { ...prevState };
const offset = getScalarScreenOffset(e, dragMatrix) - state.origin;
if (prevState.dragging) {
const stepOffset = Number.isFinite(minValue) ? minValue % step : 0;
// Translate mouse movement to value
// Give it some headroom (by increasing clamp range by 1 step)
state.internalValue = clamp(
state.internalValue + (offset * step) / stepPixelSize,
minValue - step,
maxValue + step,
);
// Clamp the final value
state.value = clamp(
state.internalValue - (state.internalValue % step) + stepOffset,
minValue,
maxValue,
);
state.origin = getScalarScreenOffset(e, dragMatrix);
} else if (Math.abs(offset) > 4) {
state.dragging = true;
}
return state;
});
};
this.handleDragEnd = (e) => {
const { onChange, onDrag } = this.props;
const { dragging, value, internalValue } = this.state;
document.body.style['pointer-events'] = 'auto';
clearTimeout(this.timer);
clearInterval(this.dragInterval);
this.setState({
dragging: false,
editing: !dragging,
origin: null,
});
document.removeEventListener('mousemove', this.handleDragMove);
document.removeEventListener('mouseup', this.handleDragEnd);
if (dragging) {
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
} else if (this.inputRef) {
const input = this.inputRef.current;
input.value = internalValue;
setTimeout(() => {
input.focus();
input.select();
}, 100);
}
};
}
render() {
const {
dragging,
editing,
value: intermediateValue,
suppressingFlicker,
} = this.state;
const {
animated,
value,
unit,
minValue,
maxValue,
unclamped,
format,
onChange,
onDrag,
children,
// Input props
height,
lineHeight,
fontSize,
} = this.props;
let displayValue = value;
if (dragging || suppressingFlicker) {
displayValue = intermediateValue;
}
// prettier-ignore
const displayElement = (
<>
{
(animated && !dragging && !suppressingFlicker) ?
(<AnimatedNumber value={displayValue} format={format} />) :
(format ? format(displayValue) : displayValue)
}
{ (unit ? ' ' + unit : '') }
</>
);
// Setup an input element
// Handles direct input via the keyboard
const inputElement = (
<input
ref={this.inputRef}
className="NumberInput__input"
style={{
display: !editing ? 'none' : undefined,
height: height,
lineHeight: lineHeight,
fontsize: fontSize,
}}
onBlur={(e) => {
if (!editing) {
return;
}
let value;
if (unclamped) {
value = parseFloat(e.target.value);
} else {
value = clamp(parseFloat(e.target.value), minValue, maxValue);
}
if (Number.isNaN(value)) {
this.setState({
editing: false,
});
return;
}
this.setState({
editing: false,
value,
});
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
}}
onKeyDown={(e) => {
if (e.keyCode === 13) {
let value;
if (unclamped) {
value = parseFloat(e.target.value);
} else {
value = clamp(parseFloat(e.target.value), minValue, maxValue);
}
if (Number.isNaN(value)) {
this.setState({
editing: false,
});
return;
}
this.setState({
editing: false,
value,
});
this.suppressFlicker();
if (onChange) {
onChange(e, value);
}
if (onDrag) {
onDrag(e, value);
}
return;
}
if (e.keyCode === 27) {
this.setState({
editing: false,
});
return;
}
}}
/>
);
// Return a part of the state for higher-level components to use.
return children({
dragging,
editing,
value,
displayValue,
displayElement,
inputElement,
handleDragStart: this.handleDragStart,
});
}
}
DraggableControl.defaultProps = {
minValue: -Infinity,
maxValue: +Infinity,
step: 1,
stepPixelSize: 1,
suppressFlicker: 50,
dragMatrix: [1, 0],
};

Some files were not shown because too many files have changed in this diff Show More