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.
Files changed (152) hide show
  1. package/.editorconfig +10 -0
  2. package/.eslintrc.cjs +78 -0
  3. package/.gitattributes +4 -0
  4. package/.prettierrc.yml +1 -0
  5. package/.vscode/extensions.json +6 -0
  6. package/.vscode/settings.json +5 -0
  7. package/README.md +1 -0
  8. package/global.d.ts +173 -0
  9. package/package.json +25 -0
  10. package/src/assets.ts +43 -0
  11. package/src/backend.ts +369 -0
  12. package/src/common/collections.ts +349 -0
  13. package/src/common/color.ts +94 -0
  14. package/src/common/events.ts +45 -0
  15. package/src/common/exhaustive.ts +19 -0
  16. package/src/common/fp.ts +38 -0
  17. package/src/common/keycodes.ts +86 -0
  18. package/src/common/keys.ts +39 -0
  19. package/src/common/math.ts +98 -0
  20. package/src/common/perf.ts +72 -0
  21. package/src/common/random.ts +32 -0
  22. package/src/common/react.ts +65 -0
  23. package/src/common/redux.ts +196 -0
  24. package/src/common/storage.js +196 -0
  25. package/src/common/string.ts +173 -0
  26. package/src/common/timer.ts +68 -0
  27. package/src/common/type-utils.ts +41 -0
  28. package/src/common/types.ts +9 -0
  29. package/src/common/uuid.ts +24 -0
  30. package/src/common/vector.ts +51 -0
  31. package/src/components/AnimatedNumber.tsx +185 -0
  32. package/src/components/Autofocus.tsx +23 -0
  33. package/src/components/Blink.jsx +69 -0
  34. package/src/components/BlockQuote.tsx +15 -0
  35. package/src/components/BodyZoneSelector.tsx +149 -0
  36. package/src/components/Box.tsx +255 -0
  37. package/src/components/Button.tsx +415 -0
  38. package/src/components/ByondUi.jsx +121 -0
  39. package/src/components/Chart.tsx +160 -0
  40. package/src/components/Collapsible.tsx +45 -0
  41. package/src/components/ColorBox.tsx +30 -0
  42. package/src/components/Dialog.tsx +85 -0
  43. package/src/components/Dimmer.tsx +19 -0
  44. package/src/components/Divider.tsx +26 -0
  45. package/src/components/DmIcon.tsx +72 -0
  46. package/src/components/DraggableControl.jsx +282 -0
  47. package/src/components/Dropdown.tsx +246 -0
  48. package/src/components/FakeTerminal.jsx +52 -0
  49. package/src/components/FitText.tsx +99 -0
  50. package/src/components/Flex.tsx +105 -0
  51. package/src/components/Grid.tsx +44 -0
  52. package/src/components/Icon.tsx +91 -0
  53. package/src/components/Image.tsx +63 -0
  54. package/src/components/InfinitePlane.jsx +192 -0
  55. package/src/components/Input.tsx +181 -0
  56. package/src/components/KeyListener.tsx +40 -0
  57. package/src/components/Knob.tsx +185 -0
  58. package/src/components/LabeledControls.tsx +50 -0
  59. package/src/components/LabeledList.tsx +130 -0
  60. package/src/components/MenuBar.tsx +238 -0
  61. package/src/components/Modal.tsx +25 -0
  62. package/src/components/NoticeBox.tsx +48 -0
  63. package/src/components/NumberInput.tsx +328 -0
  64. package/src/components/Popper.tsx +100 -0
  65. package/src/components/ProgressBar.tsx +79 -0
  66. package/src/components/RestrictedInput.jsx +301 -0
  67. package/src/components/RoundGauge.tsx +189 -0
  68. package/src/components/Section.tsx +125 -0
  69. package/src/components/Slider.tsx +173 -0
  70. package/src/components/Stack.tsx +101 -0
  71. package/src/components/StyleableSection.tsx +30 -0
  72. package/src/components/Table.tsx +90 -0
  73. package/src/components/Tabs.tsx +90 -0
  74. package/src/components/TextArea.tsx +198 -0
  75. package/src/components/TimeDisplay.jsx +64 -0
  76. package/src/components/Tooltip.tsx +147 -0
  77. package/src/components/TrackOutsideClicks.tsx +35 -0
  78. package/src/components/VirtualList.tsx +69 -0
  79. package/src/constants.ts +355 -0
  80. package/src/debug/KitchenSink.jsx +56 -0
  81. package/src/debug/actions.js +11 -0
  82. package/src/debug/hooks.js +10 -0
  83. package/src/debug/index.ts +10 -0
  84. package/src/debug/middleware.js +86 -0
  85. package/src/debug/reducer.js +22 -0
  86. package/src/debug/selectors.js +7 -0
  87. package/src/drag.ts +280 -0
  88. package/src/events.ts +237 -0
  89. package/src/focus.ts +25 -0
  90. package/src/format.ts +173 -0
  91. package/src/hotkeys.ts +212 -0
  92. package/src/http.ts +16 -0
  93. package/src/layouts/Layout.tsx +75 -0
  94. package/src/layouts/NtosWindow.tsx +162 -0
  95. package/src/layouts/Pane.tsx +56 -0
  96. package/src/layouts/Window.tsx +227 -0
  97. package/src/renderer.ts +50 -0
  98. package/styles/base.scss +32 -0
  99. package/styles/colors.scss +92 -0
  100. package/styles/components/BlockQuote.scss +20 -0
  101. package/styles/components/Button.scss +175 -0
  102. package/styles/components/ColorBox.scss +12 -0
  103. package/styles/components/Dialog.scss +105 -0
  104. package/styles/components/Dimmer.scss +22 -0
  105. package/styles/components/Divider.scss +27 -0
  106. package/styles/components/Dropdown.scss +72 -0
  107. package/styles/components/Flex.scss +31 -0
  108. package/styles/components/Icon.scss +25 -0
  109. package/styles/components/Input.scss +68 -0
  110. package/styles/components/Knob.scss +131 -0
  111. package/styles/components/LabeledList.scss +49 -0
  112. package/styles/components/MenuBar.scss +75 -0
  113. package/styles/components/Modal.scss +14 -0
  114. package/styles/components/NoticeBox.scss +65 -0
  115. package/styles/components/NumberInput.scss +76 -0
  116. package/styles/components/ProgressBar.scss +63 -0
  117. package/styles/components/RoundGauge.scss +88 -0
  118. package/styles/components/Section.scss +143 -0
  119. package/styles/components/Slider.scss +54 -0
  120. package/styles/components/Stack.scss +59 -0
  121. package/styles/components/Table.scss +44 -0
  122. package/styles/components/Tabs.scss +144 -0
  123. package/styles/components/TextArea.scss +84 -0
  124. package/styles/components/Tooltip.scss +24 -0
  125. package/styles/functions.scss +79 -0
  126. package/styles/layouts/Layout.scss +57 -0
  127. package/styles/layouts/NtosHeader.scss +20 -0
  128. package/styles/layouts/NtosWindow.scss +26 -0
  129. package/styles/layouts/TitleBar.scss +111 -0
  130. package/styles/layouts/Window.scss +103 -0
  131. package/styles/main.scss +97 -0
  132. package/styles/reset.scss +68 -0
  133. package/styles/themes/abductor.scss +68 -0
  134. package/styles/themes/admin.scss +38 -0
  135. package/styles/themes/cardtable.scss +57 -0
  136. package/styles/themes/hackerman.scss +70 -0
  137. package/styles/themes/malfunction.scss +67 -0
  138. package/styles/themes/neutral.scss +50 -0
  139. package/styles/themes/ntOS95.scss +166 -0
  140. package/styles/themes/ntos.scss +44 -0
  141. package/styles/themes/ntos_cat.scss +148 -0
  142. package/styles/themes/ntos_darkmode.scss +44 -0
  143. package/styles/themes/ntos_lightmode.scss +67 -0
  144. package/styles/themes/ntos_spooky.scss +69 -0
  145. package/styles/themes/ntos_synth.scss +99 -0
  146. package/styles/themes/ntos_terminal.scss +112 -0
  147. package/styles/themes/paper.scss +184 -0
  148. package/styles/themes/retro.scss +72 -0
  149. package/styles/themes/spookyconsole.scss +73 -0
  150. package/styles/themes/syndicate.scss +67 -0
  151. package/styles/themes/wizard.scss +68 -0
  152. 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
+ };