tgui-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +10 -0
- package/.eslintrc.cjs +78 -0
- package/.gitattributes +4 -0
- package/.prettierrc.yml +1 -0
- package/.vscode/extensions.json +6 -0
- package/.vscode/settings.json +5 -0
- package/README.md +1 -0
- package/global.d.ts +173 -0
- package/package.json +25 -0
- package/src/assets.ts +43 -0
- package/src/backend.ts +369 -0
- package/src/common/collections.ts +349 -0
- package/src/common/color.ts +94 -0
- package/src/common/events.ts +45 -0
- package/src/common/exhaustive.ts +19 -0
- package/src/common/fp.ts +38 -0
- package/src/common/keycodes.ts +86 -0
- package/src/common/keys.ts +39 -0
- package/src/common/math.ts +98 -0
- package/src/common/perf.ts +72 -0
- package/src/common/random.ts +32 -0
- package/src/common/react.ts +65 -0
- package/src/common/redux.ts +196 -0
- package/src/common/storage.js +196 -0
- package/src/common/string.ts +173 -0
- package/src/common/timer.ts +68 -0
- package/src/common/type-utils.ts +41 -0
- package/src/common/types.ts +9 -0
- package/src/common/uuid.ts +24 -0
- package/src/common/vector.ts +51 -0
- package/src/components/AnimatedNumber.tsx +185 -0
- package/src/components/Autofocus.tsx +23 -0
- package/src/components/Blink.jsx +69 -0
- package/src/components/BlockQuote.tsx +15 -0
- package/src/components/BodyZoneSelector.tsx +149 -0
- package/src/components/Box.tsx +255 -0
- package/src/components/Button.tsx +415 -0
- package/src/components/ByondUi.jsx +121 -0
- package/src/components/Chart.tsx +160 -0
- package/src/components/Collapsible.tsx +45 -0
- package/src/components/ColorBox.tsx +30 -0
- package/src/components/Dialog.tsx +85 -0
- package/src/components/Dimmer.tsx +19 -0
- package/src/components/Divider.tsx +26 -0
- package/src/components/DmIcon.tsx +72 -0
- package/src/components/DraggableControl.jsx +282 -0
- package/src/components/Dropdown.tsx +246 -0
- package/src/components/FakeTerminal.jsx +52 -0
- package/src/components/FitText.tsx +99 -0
- package/src/components/Flex.tsx +105 -0
- package/src/components/Grid.tsx +44 -0
- package/src/components/Icon.tsx +91 -0
- package/src/components/Image.tsx +63 -0
- package/src/components/InfinitePlane.jsx +192 -0
- package/src/components/Input.tsx +181 -0
- package/src/components/KeyListener.tsx +40 -0
- package/src/components/Knob.tsx +185 -0
- package/src/components/LabeledControls.tsx +50 -0
- package/src/components/LabeledList.tsx +130 -0
- package/src/components/MenuBar.tsx +238 -0
- package/src/components/Modal.tsx +25 -0
- package/src/components/NoticeBox.tsx +48 -0
- package/src/components/NumberInput.tsx +328 -0
- package/src/components/Popper.tsx +100 -0
- package/src/components/ProgressBar.tsx +79 -0
- package/src/components/RestrictedInput.jsx +301 -0
- package/src/components/RoundGauge.tsx +189 -0
- package/src/components/Section.tsx +125 -0
- package/src/components/Slider.tsx +173 -0
- package/src/components/Stack.tsx +101 -0
- package/src/components/StyleableSection.tsx +30 -0
- package/src/components/Table.tsx +90 -0
- package/src/components/Tabs.tsx +90 -0
- package/src/components/TextArea.tsx +198 -0
- package/src/components/TimeDisplay.jsx +64 -0
- package/src/components/Tooltip.tsx +147 -0
- package/src/components/TrackOutsideClicks.tsx +35 -0
- package/src/components/VirtualList.tsx +69 -0
- package/src/constants.ts +355 -0
- package/src/debug/KitchenSink.jsx +56 -0
- package/src/debug/actions.js +11 -0
- package/src/debug/hooks.js +10 -0
- package/src/debug/index.ts +10 -0
- package/src/debug/middleware.js +86 -0
- package/src/debug/reducer.js +22 -0
- package/src/debug/selectors.js +7 -0
- package/src/drag.ts +280 -0
- package/src/events.ts +237 -0
- package/src/focus.ts +25 -0
- package/src/format.ts +173 -0
- package/src/hotkeys.ts +212 -0
- package/src/http.ts +16 -0
- package/src/layouts/Layout.tsx +75 -0
- package/src/layouts/NtosWindow.tsx +162 -0
- package/src/layouts/Pane.tsx +56 -0
- package/src/layouts/Window.tsx +227 -0
- package/src/renderer.ts +50 -0
- package/styles/base.scss +32 -0
- package/styles/colors.scss +92 -0
- package/styles/components/BlockQuote.scss +20 -0
- package/styles/components/Button.scss +175 -0
- package/styles/components/ColorBox.scss +12 -0
- package/styles/components/Dialog.scss +105 -0
- package/styles/components/Dimmer.scss +22 -0
- package/styles/components/Divider.scss +27 -0
- package/styles/components/Dropdown.scss +72 -0
- package/styles/components/Flex.scss +31 -0
- package/styles/components/Icon.scss +25 -0
- package/styles/components/Input.scss +68 -0
- package/styles/components/Knob.scss +131 -0
- package/styles/components/LabeledList.scss +49 -0
- package/styles/components/MenuBar.scss +75 -0
- package/styles/components/Modal.scss +14 -0
- package/styles/components/NoticeBox.scss +65 -0
- package/styles/components/NumberInput.scss +76 -0
- package/styles/components/ProgressBar.scss +63 -0
- package/styles/components/RoundGauge.scss +88 -0
- package/styles/components/Section.scss +143 -0
- package/styles/components/Slider.scss +54 -0
- package/styles/components/Stack.scss +59 -0
- package/styles/components/Table.scss +44 -0
- package/styles/components/Tabs.scss +144 -0
- package/styles/components/TextArea.scss +84 -0
- package/styles/components/Tooltip.scss +24 -0
- package/styles/functions.scss +79 -0
- package/styles/layouts/Layout.scss +57 -0
- package/styles/layouts/NtosHeader.scss +20 -0
- package/styles/layouts/NtosWindow.scss +26 -0
- package/styles/layouts/TitleBar.scss +111 -0
- package/styles/layouts/Window.scss +103 -0
- package/styles/main.scss +97 -0
- package/styles/reset.scss +68 -0
- package/styles/themes/abductor.scss +68 -0
- package/styles/themes/admin.scss +38 -0
- package/styles/themes/cardtable.scss +57 -0
- package/styles/themes/hackerman.scss +70 -0
- package/styles/themes/malfunction.scss +67 -0
- package/styles/themes/neutral.scss +50 -0
- package/styles/themes/ntOS95.scss +166 -0
- package/styles/themes/ntos.scss +44 -0
- package/styles/themes/ntos_cat.scss +148 -0
- package/styles/themes/ntos_darkmode.scss +44 -0
- package/styles/themes/ntos_lightmode.scss +67 -0
- package/styles/themes/ntos_spooky.scss +69 -0
- package/styles/themes/ntos_synth.scss +99 -0
- package/styles/themes/ntos_terminal.scss +112 -0
- package/styles/themes/paper.scss +184 -0
- package/styles/themes/retro.scss +72 -0
- package/styles/themes/spookyconsole.scss +73 -0
- package/styles/themes/syndicate.scss +67 -0
- package/styles/themes/wizard.scss +68 -0
- package/tsconfig.json +34 -0
package/src/events.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized browser focus events and BYOND-specific focus helpers.
|
|
3
|
+
*
|
|
4
|
+
* @file
|
|
5
|
+
* @copyright 2020 Aleksej Komarov
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EventEmitter } from './common/events';
|
|
10
|
+
import {
|
|
11
|
+
KEY_ALT,
|
|
12
|
+
KEY_CTRL,
|
|
13
|
+
KEY_F1,
|
|
14
|
+
KEY_F12,
|
|
15
|
+
KEY_SHIFT,
|
|
16
|
+
} from './common/keycodes';
|
|
17
|
+
|
|
18
|
+
export const globalEvents = new EventEmitter();
|
|
19
|
+
let ignoreWindowFocus = false;
|
|
20
|
+
|
|
21
|
+
export const setupGlobalEvents = (
|
|
22
|
+
options: { ignoreWindowFocus?: boolean } = {}
|
|
23
|
+
): void => {
|
|
24
|
+
ignoreWindowFocus = !!options.ignoreWindowFocus;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Window focus
|
|
28
|
+
// --------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
let windowFocusTimeout: ReturnType<typeof setTimeout> | null;
|
|
31
|
+
let windowFocused = true;
|
|
32
|
+
|
|
33
|
+
// Pretend to always be in focus.
|
|
34
|
+
const setWindowFocus = (value: boolean, delayed?: boolean) => {
|
|
35
|
+
if (ignoreWindowFocus) {
|
|
36
|
+
windowFocused = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (windowFocusTimeout) {
|
|
40
|
+
clearTimeout(windowFocusTimeout);
|
|
41
|
+
windowFocusTimeout = null;
|
|
42
|
+
}
|
|
43
|
+
if (delayed) {
|
|
44
|
+
windowFocusTimeout = setTimeout(() => setWindowFocus(value));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (windowFocused !== value) {
|
|
48
|
+
windowFocused = value;
|
|
49
|
+
globalEvents.emit(value ? 'window-focus' : 'window-blur');
|
|
50
|
+
globalEvents.emit('window-focus-change', value);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Focus stealing
|
|
55
|
+
// --------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
let focusStolenBy: HTMLElement | null = null;
|
|
58
|
+
|
|
59
|
+
export const canStealFocus = (node: HTMLElement) => {
|
|
60
|
+
const tag = String(node.tagName).toLowerCase();
|
|
61
|
+
return tag === 'input' || tag === 'textarea';
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const stealFocus = (node: HTMLElement) => {
|
|
65
|
+
releaseStolenFocus();
|
|
66
|
+
focusStolenBy = node;
|
|
67
|
+
focusStolenBy.addEventListener('blur', releaseStolenFocus);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const releaseStolenFocus = () => {
|
|
71
|
+
if (focusStolenBy) {
|
|
72
|
+
focusStolenBy.removeEventListener('blur', releaseStolenFocus);
|
|
73
|
+
focusStolenBy = null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Focus follows the mouse
|
|
78
|
+
// --------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
let focusedNode: HTMLElement | null = null;
|
|
81
|
+
let lastVisitedNode: HTMLElement | null = null;
|
|
82
|
+
const trackedNodes: HTMLElement[] = [];
|
|
83
|
+
|
|
84
|
+
export const addScrollableNode = (node: HTMLElement) => {
|
|
85
|
+
trackedNodes.push(node);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const removeScrollableNode = (node: HTMLElement) => {
|
|
89
|
+
const index = trackedNodes.indexOf(node);
|
|
90
|
+
if (index >= 0) {
|
|
91
|
+
trackedNodes.splice(index, 1);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const focusNearestTrackedParent = (node: HTMLElement | null) => {
|
|
96
|
+
if (focusStolenBy || !windowFocused) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const body = document.body;
|
|
100
|
+
while (node && node !== body) {
|
|
101
|
+
if (trackedNodes.includes(node)) {
|
|
102
|
+
// NOTE: Contains is a DOM4 method
|
|
103
|
+
if (node.contains(focusedNode)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
focusedNode = node;
|
|
107
|
+
node.focus();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
node = node.parentElement;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
window.addEventListener('mousemove', (e) => {
|
|
115
|
+
const node = e.target as HTMLElement;
|
|
116
|
+
if (node !== lastVisitedNode) {
|
|
117
|
+
lastVisitedNode = node;
|
|
118
|
+
focusNearestTrackedParent(node);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Focus event hooks
|
|
123
|
+
// --------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
window.addEventListener('focusin', (e) => {
|
|
126
|
+
lastVisitedNode = null;
|
|
127
|
+
focusedNode = e.target as HTMLElement;
|
|
128
|
+
setWindowFocus(true);
|
|
129
|
+
if (canStealFocus(e.target as HTMLElement)) {
|
|
130
|
+
stealFocus(e.target as HTMLElement);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
window.addEventListener('focusout', (e) => {
|
|
135
|
+
lastVisitedNode = null;
|
|
136
|
+
setWindowFocus(false, true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
window.addEventListener('blur', (e) => {
|
|
140
|
+
lastVisitedNode = null;
|
|
141
|
+
setWindowFocus(false, true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
window.addEventListener('beforeunload', (e) => {
|
|
145
|
+
setWindowFocus(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Key events
|
|
149
|
+
// --------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
const keyHeldByCode: Record<number, boolean> = {};
|
|
152
|
+
|
|
153
|
+
export class KeyEvent {
|
|
154
|
+
event: KeyboardEvent;
|
|
155
|
+
type: 'keydown' | 'keyup';
|
|
156
|
+
code: number;
|
|
157
|
+
ctrl: boolean;
|
|
158
|
+
shift: boolean;
|
|
159
|
+
alt: boolean;
|
|
160
|
+
repeat: boolean;
|
|
161
|
+
_str?: string;
|
|
162
|
+
|
|
163
|
+
constructor(e: KeyboardEvent, type: 'keydown' | 'keyup', repeat?: boolean) {
|
|
164
|
+
this.event = e;
|
|
165
|
+
this.type = type;
|
|
166
|
+
this.code = e.keyCode;
|
|
167
|
+
this.ctrl = e.ctrlKey;
|
|
168
|
+
this.shift = e.shiftKey;
|
|
169
|
+
this.alt = e.altKey;
|
|
170
|
+
this.repeat = !!repeat;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
hasModifierKeys() {
|
|
174
|
+
return this.ctrl || this.alt || this.shift;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
isModifierKey() {
|
|
178
|
+
return (
|
|
179
|
+
this.code === KEY_CTRL || this.code === KEY_SHIFT || this.code === KEY_ALT
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
isDown() {
|
|
184
|
+
return this.type === 'keydown';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
isUp() {
|
|
188
|
+
return this.type === 'keyup';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
toString() {
|
|
192
|
+
if (this._str) {
|
|
193
|
+
return this._str;
|
|
194
|
+
}
|
|
195
|
+
this._str = '';
|
|
196
|
+
if (this.ctrl) {
|
|
197
|
+
this._str += 'Ctrl+';
|
|
198
|
+
}
|
|
199
|
+
if (this.alt) {
|
|
200
|
+
this._str += 'Alt+';
|
|
201
|
+
}
|
|
202
|
+
if (this.shift) {
|
|
203
|
+
this._str += 'Shift+';
|
|
204
|
+
}
|
|
205
|
+
if (this.code >= 48 && this.code <= 90) {
|
|
206
|
+
this._str += String.fromCharCode(this.code);
|
|
207
|
+
} else if (this.code >= KEY_F1 && this.code <= KEY_F12) {
|
|
208
|
+
this._str += 'F' + (this.code - 111);
|
|
209
|
+
} else {
|
|
210
|
+
this._str += '[' + this.code + ']';
|
|
211
|
+
}
|
|
212
|
+
return this._str;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// IE8: Keydown event is only available on document.
|
|
217
|
+
document.addEventListener('keydown', (e) => {
|
|
218
|
+
if (canStealFocus(e.target as HTMLElement)) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const code = e.keyCode;
|
|
222
|
+
const key = new KeyEvent(e, 'keydown', keyHeldByCode[code]);
|
|
223
|
+
globalEvents.emit('keydown', key);
|
|
224
|
+
globalEvents.emit('key', key);
|
|
225
|
+
keyHeldByCode[code] = true;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
document.addEventListener('keyup', (e) => {
|
|
229
|
+
if (canStealFocus(e.target as HTMLElement)) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const code = e.keyCode;
|
|
233
|
+
const key = new KeyEvent(e, 'keyup');
|
|
234
|
+
globalEvents.emit('keyup', key);
|
|
235
|
+
globalEvents.emit('key', key);
|
|
236
|
+
keyHeldByCode[code] = false;
|
|
237
|
+
});
|
package/src/focus.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Various focus helpers.
|
|
3
|
+
*
|
|
4
|
+
* @file
|
|
5
|
+
* @copyright 2020 Aleksej Komarov
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Moves focus to the BYOND map window.
|
|
11
|
+
*/
|
|
12
|
+
export const focusMap = () => {
|
|
13
|
+
Byond.winset('mapwindow.map', {
|
|
14
|
+
focus: true,
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Moves focus to the browser window.
|
|
20
|
+
*/
|
|
21
|
+
export const focusWindow = () => {
|
|
22
|
+
Byond.winset(Byond.windowId, {
|
|
23
|
+
focus: true,
|
|
24
|
+
});
|
|
25
|
+
};
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file
|
|
3
|
+
* @copyright 2020 Aleksej Komarov
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SI_SYMBOLS = [
|
|
8
|
+
'f', // femto
|
|
9
|
+
'p', // pico
|
|
10
|
+
'n', // nano
|
|
11
|
+
'μ', // micro
|
|
12
|
+
'm', // milli
|
|
13
|
+
// NOTE: This is a space for a reason. When we right align si numbers,
|
|
14
|
+
// in monospace mode, we want to units and numbers stay in their respective
|
|
15
|
+
// columns. If rendering in HTML mode, this space will collapse into
|
|
16
|
+
// a single space anyway.
|
|
17
|
+
' ', // base
|
|
18
|
+
'k', // kilo
|
|
19
|
+
'M', // mega
|
|
20
|
+
'G', // giga
|
|
21
|
+
'T', // tera
|
|
22
|
+
'P', // peta
|
|
23
|
+
'E', // exa
|
|
24
|
+
'Z', // zetta
|
|
25
|
+
'Y', // yotta
|
|
26
|
+
'R', // ronna
|
|
27
|
+
'Q', // quecca
|
|
28
|
+
'F',
|
|
29
|
+
'N',
|
|
30
|
+
'H',
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const SI_BASE_INDEX = SI_SYMBOLS.indexOf(' ');
|
|
34
|
+
|
|
35
|
+
// Formats a number to a human readable form, with a custom unit
|
|
36
|
+
export const formatSiUnit = (
|
|
37
|
+
value: number,
|
|
38
|
+
minBase1000 = -SI_BASE_INDEX,
|
|
39
|
+
unit = '',
|
|
40
|
+
): string => {
|
|
41
|
+
if (!isFinite(value)) {
|
|
42
|
+
return value.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const realBase10 = Math.floor(Math.log10(Math.abs(value)));
|
|
46
|
+
const base10 = Math.max(minBase1000 * 3, realBase10);
|
|
47
|
+
const base1000 = Math.floor(base10 / 3);
|
|
48
|
+
const symbol =
|
|
49
|
+
SI_SYMBOLS[Math.min(base1000 + SI_BASE_INDEX, SI_SYMBOLS.length - 1)];
|
|
50
|
+
|
|
51
|
+
const scaledValue = value / Math.pow(1000, base1000);
|
|
52
|
+
|
|
53
|
+
let formattedValue = scaledValue.toFixed(2);
|
|
54
|
+
if (formattedValue.endsWith('.00')) {
|
|
55
|
+
formattedValue = formattedValue.slice(0, -3);
|
|
56
|
+
} else if (formattedValue.endsWith('.0')) {
|
|
57
|
+
formattedValue = formattedValue.slice(0, -2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return `${formattedValue} ${symbol.trim()}${unit}`.trim();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Formats a number to a human readable form, with power (W) as the unit
|
|
64
|
+
export const formatPower = (value: number, minBase1000 = 0) => {
|
|
65
|
+
return formatSiUnit(value, minBase1000, 'W');
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const formatEnergy = (value: number, minBase1000 = 0) => {
|
|
69
|
+
return formatSiUnit(value, minBase1000, 'J');
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Formats a number as a currency string
|
|
73
|
+
export const formatMoney = (value: number, precision = 0) => {
|
|
74
|
+
if (!Number.isFinite(value)) {
|
|
75
|
+
return String(value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Round the number and make it fixed precision
|
|
79
|
+
const roundedValue = Number(value.toFixed(precision));
|
|
80
|
+
|
|
81
|
+
// Handle the negative sign
|
|
82
|
+
const isNegative = roundedValue < 0;
|
|
83
|
+
const absoluteValue = Math.abs(roundedValue);
|
|
84
|
+
|
|
85
|
+
// Convert to string and place thousand separators
|
|
86
|
+
const parts = absoluteValue.toString().split('.');
|
|
87
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '\u2009'); // Thin space
|
|
88
|
+
|
|
89
|
+
const formattedValue = parts.join('.');
|
|
90
|
+
|
|
91
|
+
return isNegative ? `-${formattedValue}` : formattedValue;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Formats a floating point number as a number on the decibel scale
|
|
95
|
+
export const formatDb = (value: number) => {
|
|
96
|
+
const db = 20 * Math.log10(value);
|
|
97
|
+
const sign = db >= 0 ? '+' : '-';
|
|
98
|
+
let formatted: string | number = Math.abs(db);
|
|
99
|
+
|
|
100
|
+
if (formatted === Infinity) {
|
|
101
|
+
formatted = 'Inf';
|
|
102
|
+
} else {
|
|
103
|
+
formatted = formatted.toFixed(2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return `${sign}${formatted} dB`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const SI_BASE_TEN_UNITS = [
|
|
110
|
+
'',
|
|
111
|
+
'· 10³', // kilo
|
|
112
|
+
'· 10⁶', // mega
|
|
113
|
+
'· 10⁹', // giga
|
|
114
|
+
'· 10¹²', // tera
|
|
115
|
+
'· 10¹⁵', // peta
|
|
116
|
+
'· 10¹⁸', // exa
|
|
117
|
+
'· 10²¹', // zetta
|
|
118
|
+
'· 10²⁴', // yotta
|
|
119
|
+
'· 10²⁷', // ronna
|
|
120
|
+
'· 10³⁰', // quecca
|
|
121
|
+
'· 10³³',
|
|
122
|
+
'· 10³⁶',
|
|
123
|
+
'· 10³⁹',
|
|
124
|
+
] as const;
|
|
125
|
+
|
|
126
|
+
// Converts a number to a string with SI base 10 units
|
|
127
|
+
export const formatSiBaseTenUnit = (
|
|
128
|
+
value: number,
|
|
129
|
+
minBase1000 = 0,
|
|
130
|
+
unit = '',
|
|
131
|
+
): string => {
|
|
132
|
+
if (!isFinite(value)) {
|
|
133
|
+
return 'NaN';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const realBase10 = Math.floor(Math.log10(value));
|
|
137
|
+
const base10 = Math.max(minBase1000 * 3, realBase10);
|
|
138
|
+
const base1000 = Math.floor(base10 / 3);
|
|
139
|
+
const symbol = SI_BASE_TEN_UNITS[base1000];
|
|
140
|
+
|
|
141
|
+
const scaledValue = value / Math.pow(1000, base1000);
|
|
142
|
+
const precision = Math.max(0, 2 - (base10 % 3));
|
|
143
|
+
const formattedValue = scaledValue.toFixed(precision);
|
|
144
|
+
|
|
145
|
+
return `${formattedValue} ${symbol} ${unit}`.trim();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Formats decisecond count into HH:MM:SS display by default
|
|
150
|
+
* "short" format does not pad and adds hms suffixes
|
|
151
|
+
*/
|
|
152
|
+
export const formatTime = (
|
|
153
|
+
val: number,
|
|
154
|
+
formatType: 'short' | 'default' = 'default',
|
|
155
|
+
): string => {
|
|
156
|
+
const totalSeconds = Math.floor(val / 10);
|
|
157
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
158
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
159
|
+
const seconds = totalSeconds % 60;
|
|
160
|
+
|
|
161
|
+
if (formatType === 'short') {
|
|
162
|
+
const hoursFormatted = hours > 0 ? `${hours}h` : '';
|
|
163
|
+
const minutesFormatted = minutes > 0 ? `${minutes}m` : '';
|
|
164
|
+
const secondsFormatted = seconds > 0 ? `${seconds}s` : '';
|
|
165
|
+
return `${hoursFormatted}${minutesFormatted}${secondsFormatted}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const hoursPadded = String(hours).padStart(2, '0');
|
|
169
|
+
const minutesPadded = String(minutes).padStart(2, '0');
|
|
170
|
+
const secondsPadded = String(seconds).padStart(2, '0');
|
|
171
|
+
|
|
172
|
+
return `${hoursPadded}:${minutesPadded}:${secondsPadded}`;
|
|
173
|
+
};
|
package/src/hotkeys.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file
|
|
3
|
+
* @copyright 2020 Aleksej Komarov
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as keycodes from './common/keycodes';
|
|
8
|
+
|
|
9
|
+
import { globalEvents, KeyEvent } from './events';
|
|
10
|
+
|
|
11
|
+
// BYOND macros, in `key: command` format.
|
|
12
|
+
const byondMacros: Record<string, string> = {};
|
|
13
|
+
|
|
14
|
+
// Default set of acquired keys, which will not be sent to BYOND.
|
|
15
|
+
const hotKeysAcquired = [
|
|
16
|
+
keycodes.KEY_ESCAPE,
|
|
17
|
+
keycodes.KEY_ENTER,
|
|
18
|
+
keycodes.KEY_SPACE,
|
|
19
|
+
keycodes.KEY_TAB,
|
|
20
|
+
keycodes.KEY_CTRL,
|
|
21
|
+
keycodes.KEY_SHIFT,
|
|
22
|
+
keycodes.KEY_UP,
|
|
23
|
+
keycodes.KEY_DOWN,
|
|
24
|
+
keycodes.KEY_LEFT,
|
|
25
|
+
keycodes.KEY_RIGHT,
|
|
26
|
+
keycodes.KEY_F5,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// State of passed-through keys.
|
|
30
|
+
const keyState: Record<string, boolean> = {};
|
|
31
|
+
|
|
32
|
+
// Custom listeners for key events
|
|
33
|
+
const keyListeners: ((key: KeyEvent) => void)[] = [];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Converts a browser keycode to BYOND keycode.
|
|
37
|
+
*/
|
|
38
|
+
const keyCodeToByond = (keyCode: number) => {
|
|
39
|
+
if (keyCode === 16) return 'Shift';
|
|
40
|
+
if (keyCode === 17) return 'Ctrl';
|
|
41
|
+
if (keyCode === 18) return 'Alt';
|
|
42
|
+
if (keyCode === 33) return 'Northeast';
|
|
43
|
+
if (keyCode === 34) return 'Southeast';
|
|
44
|
+
if (keyCode === 35) return 'Southwest';
|
|
45
|
+
if (keyCode === 36) return 'Northwest';
|
|
46
|
+
if (keyCode === 37) return 'West';
|
|
47
|
+
if (keyCode === 38) return 'North';
|
|
48
|
+
if (keyCode === 39) return 'East';
|
|
49
|
+
if (keyCode === 40) return 'South';
|
|
50
|
+
if (keyCode === 45) return 'Insert';
|
|
51
|
+
if (keyCode === 46) return 'Delete';
|
|
52
|
+
|
|
53
|
+
if ((keyCode >= 48 && keyCode <= 57) || (keyCode >= 65 && keyCode <= 90)) {
|
|
54
|
+
return String.fromCharCode(keyCode);
|
|
55
|
+
}
|
|
56
|
+
if (keyCode >= 96 && keyCode <= 105) {
|
|
57
|
+
return 'Numpad' + (keyCode - 96);
|
|
58
|
+
}
|
|
59
|
+
if (keyCode >= 112 && keyCode <= 123) {
|
|
60
|
+
return 'F' + (keyCode - 111);
|
|
61
|
+
}
|
|
62
|
+
if (keyCode === 188) return ',';
|
|
63
|
+
if (keyCode === 189) return '-';
|
|
64
|
+
if (keyCode === 190) return '.';
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keyboard passthrough logic. This allows you to keep doing things
|
|
69
|
+
* in game while the browser window is focused.
|
|
70
|
+
*/
|
|
71
|
+
const handlePassthrough = (key: KeyEvent) => {
|
|
72
|
+
const keyString = String(key);
|
|
73
|
+
// In addition to F5, support reloading with Ctrl+R and Ctrl+F5
|
|
74
|
+
if (keyString === 'Ctrl+F5' || keyString === 'Ctrl+R') {
|
|
75
|
+
location.reload();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Prevent passthrough on Ctrl+F
|
|
79
|
+
if (keyString === 'Ctrl+F') {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// NOTE: Alt modifier is pretty bad and sticky in IE11.
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
key.event.defaultPrevented ||
|
|
86
|
+
key.isModifierKey() ||
|
|
87
|
+
hotKeysAcquired.includes(key.code)
|
|
88
|
+
) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const byondKeyCode = keyCodeToByond(key.code);
|
|
92
|
+
if (!byondKeyCode) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Macro
|
|
96
|
+
const macro = byondMacros[byondKeyCode];
|
|
97
|
+
if (macro) {
|
|
98
|
+
return Byond.command(macro);
|
|
99
|
+
}
|
|
100
|
+
// KeyDown
|
|
101
|
+
if (key.isDown() && !keyState[byondKeyCode]) {
|
|
102
|
+
keyState[byondKeyCode] = true;
|
|
103
|
+
const command = `KeyDown "${byondKeyCode}"`;
|
|
104
|
+
return Byond.command(command);
|
|
105
|
+
}
|
|
106
|
+
// KeyUp
|
|
107
|
+
if (key.isUp() && keyState[byondKeyCode]) {
|
|
108
|
+
keyState[byondKeyCode] = false;
|
|
109
|
+
const command = `KeyUp "${byondKeyCode}"`;
|
|
110
|
+
return Byond.command(command);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Acquires a lock on the hotkey, which prevents it from being
|
|
116
|
+
* passed through to BYOND.
|
|
117
|
+
*/
|
|
118
|
+
export const acquireHotKey = (keyCode: number) => {
|
|
119
|
+
hotKeysAcquired.push(keyCode);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Makes the hotkey available to BYOND again.
|
|
124
|
+
*/
|
|
125
|
+
export const releaseHotKey = (keyCode: number) => {
|
|
126
|
+
const index = hotKeysAcquired.indexOf(keyCode);
|
|
127
|
+
if (index >= 0) {
|
|
128
|
+
hotKeysAcquired.splice(index, 1);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const releaseHeldKeys = () => {
|
|
133
|
+
for (let byondKeyCode of Object.keys(keyState)) {
|
|
134
|
+
if (keyState[byondKeyCode]) {
|
|
135
|
+
keyState[byondKeyCode] = false;
|
|
136
|
+
Byond.command(`KeyUp "${byondKeyCode}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
type ByondSkinMacro = {
|
|
142
|
+
command: string;
|
|
143
|
+
name: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const setupHotKeys = () => {
|
|
147
|
+
// Read macros
|
|
148
|
+
Byond.winget('default.*').then((data: Record<string, string>) => {
|
|
149
|
+
// Group each macro by ref
|
|
150
|
+
const groupedByRef: Record<string, ByondSkinMacro> = {};
|
|
151
|
+
for (let key of Object.keys(data)) {
|
|
152
|
+
const keyPath = key.split('.');
|
|
153
|
+
const ref = keyPath[1];
|
|
154
|
+
const prop = keyPath[2];
|
|
155
|
+
if (ref && prop) {
|
|
156
|
+
// This piece of code imperatively adds each property to a
|
|
157
|
+
// ByondSkinMacro object in the order we meet it, which is hard
|
|
158
|
+
// to express safely in typescript.
|
|
159
|
+
if (!groupedByRef[ref]) {
|
|
160
|
+
groupedByRef[ref] = {} as any;
|
|
161
|
+
}
|
|
162
|
+
groupedByRef[ref][prop] = data[key];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Insert macros
|
|
166
|
+
const escapedQuotRegex = /\\"/g;
|
|
167
|
+
|
|
168
|
+
const unescape = (str: string) =>
|
|
169
|
+
str.substring(1, str.length - 1).replace(escapedQuotRegex, '"');
|
|
170
|
+
for (let ref of Object.keys(groupedByRef)) {
|
|
171
|
+
const macro = groupedByRef[ref];
|
|
172
|
+
const byondKeyName = unescape(macro.name);
|
|
173
|
+
byondMacros[byondKeyName] = unescape(macro.command);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// Setup event handlers
|
|
177
|
+
globalEvents.on('window-blur', () => {
|
|
178
|
+
releaseHeldKeys();
|
|
179
|
+
});
|
|
180
|
+
globalEvents.on('key', (key: KeyEvent) => {
|
|
181
|
+
for (const keyListener of keyListeners) {
|
|
182
|
+
keyListener(key);
|
|
183
|
+
}
|
|
184
|
+
handlePassthrough(key);
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Registers for any key events, such as key down or key up.
|
|
190
|
+
* This should be preferred over directly connecting to keydown/keyup
|
|
191
|
+
* as it lets tgui prevent the key from reaching BYOND.
|
|
192
|
+
*
|
|
193
|
+
* If using in a component, prefer KeyListener, which automatically handles
|
|
194
|
+
* stopping listening when unmounting.
|
|
195
|
+
*
|
|
196
|
+
* @param callback The function to call whenever a key event occurs
|
|
197
|
+
* @returns A callback to stop listening
|
|
198
|
+
*/
|
|
199
|
+
export const listenForKeyEvents = (callback: (key: KeyEvent) => void) => {
|
|
200
|
+
keyListeners.push(callback);
|
|
201
|
+
|
|
202
|
+
let removed = false;
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
if (removed) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
removed = true;
|
|
210
|
+
keyListeners.splice(keyListeners.indexOf(callback), 1);
|
|
211
|
+
};
|
|
212
|
+
};
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An equivalent to `fetch`, except will automatically retry.
|
|
3
|
+
*/
|
|
4
|
+
export const fetchRetry = (
|
|
5
|
+
url: string,
|
|
6
|
+
options?: RequestInit,
|
|
7
|
+
retryTimer: number = 1000,
|
|
8
|
+
): Promise<Response> => {
|
|
9
|
+
return fetch(url, options).catch(() => {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
setTimeout(() => {
|
|
12
|
+
fetchRetry(url, options, retryTimer).then(resolve);
|
|
13
|
+
}, retryTimer);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
};
|