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/backend.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file provides a clear separation layer between backend updates
|
|
3
|
+
* and what state our React app sees.
|
|
4
|
+
*
|
|
5
|
+
* Sometimes backend can response without a "data" field, but our final
|
|
6
|
+
* state will still contain previous "data" because we are merging
|
|
7
|
+
* the response with already existing state.
|
|
8
|
+
*
|
|
9
|
+
* @file
|
|
10
|
+
* @copyright 2020 Aleksej Komarov
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { perf } from './common/perf';
|
|
15
|
+
import { createAction } from './common/redux';
|
|
16
|
+
|
|
17
|
+
import { setupDrag } from './drag';
|
|
18
|
+
import { globalEvents } from './events';
|
|
19
|
+
import { focusMap } from './focus';
|
|
20
|
+
import { resumeRenderer, suspendRenderer } from './renderer';
|
|
21
|
+
|
|
22
|
+
export let globalStore;
|
|
23
|
+
|
|
24
|
+
export const setGlobalStore = (store) => {
|
|
25
|
+
globalStore = store;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const backendUpdate = createAction('backend/update');
|
|
29
|
+
export const backendSetSharedState = createAction('backend/setSharedState');
|
|
30
|
+
export const backendSuspendStart = createAction('backend/suspendStart');
|
|
31
|
+
|
|
32
|
+
export const backendSuspendSuccess = () => ({
|
|
33
|
+
type: 'backend/suspendSuccess',
|
|
34
|
+
payload: {
|
|
35
|
+
timestamp: Date.now(),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const initialState = {
|
|
40
|
+
config: {},
|
|
41
|
+
data: {},
|
|
42
|
+
shared: {},
|
|
43
|
+
// Start as suspended
|
|
44
|
+
suspended: Date.now(),
|
|
45
|
+
suspending: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const backendReducer = (state = initialState, action) => {
|
|
49
|
+
const { type, payload } = action;
|
|
50
|
+
|
|
51
|
+
if (type === 'backend/update') {
|
|
52
|
+
// Merge config
|
|
53
|
+
const config = {
|
|
54
|
+
...state.config,
|
|
55
|
+
...payload.config,
|
|
56
|
+
};
|
|
57
|
+
// Merge data
|
|
58
|
+
const data = {
|
|
59
|
+
...state.data,
|
|
60
|
+
...payload.static_data,
|
|
61
|
+
...payload.data,
|
|
62
|
+
};
|
|
63
|
+
// Merge shared states
|
|
64
|
+
const shared = { ...state.shared };
|
|
65
|
+
if (payload.shared) {
|
|
66
|
+
for (let key of Object.keys(payload.shared)) {
|
|
67
|
+
const value = payload.shared[key];
|
|
68
|
+
if (value === '') {
|
|
69
|
+
shared[key] = undefined;
|
|
70
|
+
} else {
|
|
71
|
+
shared[key] = JSON.parse(value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Return new state
|
|
76
|
+
return {
|
|
77
|
+
...state,
|
|
78
|
+
config,
|
|
79
|
+
data,
|
|
80
|
+
shared,
|
|
81
|
+
suspended: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (type === 'backend/setSharedState') {
|
|
86
|
+
const { key, nextState } = payload;
|
|
87
|
+
return {
|
|
88
|
+
...state,
|
|
89
|
+
shared: {
|
|
90
|
+
...state.shared,
|
|
91
|
+
[key]: nextState,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type === 'backend/suspendStart') {
|
|
97
|
+
return {
|
|
98
|
+
...state,
|
|
99
|
+
suspending: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (type === 'backend/suspendSuccess') {
|
|
104
|
+
const { timestamp } = payload;
|
|
105
|
+
return {
|
|
106
|
+
...state,
|
|
107
|
+
data: {},
|
|
108
|
+
shared: {},
|
|
109
|
+
config: {
|
|
110
|
+
...state.config,
|
|
111
|
+
title: '',
|
|
112
|
+
status: 1,
|
|
113
|
+
},
|
|
114
|
+
suspending: false,
|
|
115
|
+
suspended: timestamp,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return state;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const backendMiddleware = (store) => {
|
|
123
|
+
let fancyState;
|
|
124
|
+
let suspendInterval;
|
|
125
|
+
|
|
126
|
+
return (next) => (action) => {
|
|
127
|
+
const { suspended } = selectBackend(store.getState());
|
|
128
|
+
const { type, payload } = action;
|
|
129
|
+
|
|
130
|
+
if (type === 'update') {
|
|
131
|
+
store.dispatch(backendUpdate(payload));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (type === 'suspend') {
|
|
136
|
+
store.dispatch(backendSuspendSuccess());
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (type === 'ping') {
|
|
141
|
+
Byond.sendMessage('ping/reply');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (type === 'byond/mousedown') {
|
|
146
|
+
globalEvents.emit('byond/mousedown');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (type === 'byond/mouseup') {
|
|
150
|
+
globalEvents.emit('byond/mouseup');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (type === 'byond/ctrldown') {
|
|
154
|
+
globalEvents.emit('byond/ctrldown');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (type === 'byond/ctrlup') {
|
|
158
|
+
globalEvents.emit('byond/ctrlup');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (type === 'backend/suspendStart' && !suspendInterval) {
|
|
162
|
+
// Keep sending suspend messages until it succeeds.
|
|
163
|
+
// It may fail multiple times due to topic rate limiting.
|
|
164
|
+
const suspendFn = () => Byond.sendMessage('suspend');
|
|
165
|
+
suspendFn();
|
|
166
|
+
suspendInterval = setInterval(suspendFn, 2000);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (type === 'backend/suspendSuccess') {
|
|
170
|
+
suspendRenderer();
|
|
171
|
+
clearInterval(suspendInterval);
|
|
172
|
+
suspendInterval = undefined;
|
|
173
|
+
Byond.winset(Byond.windowId, {
|
|
174
|
+
'is-visible': false,
|
|
175
|
+
});
|
|
176
|
+
setTimeout(() => focusMap());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (type === 'backend/update') {
|
|
180
|
+
const fancy = payload.config?.window?.fancy;
|
|
181
|
+
// Initialize fancy state
|
|
182
|
+
if (fancyState === undefined) {
|
|
183
|
+
fancyState = fancy;
|
|
184
|
+
}
|
|
185
|
+
// React to changes in fancy
|
|
186
|
+
else if (fancyState !== fancy) {
|
|
187
|
+
fancyState = fancy;
|
|
188
|
+
Byond.winset(Byond.windowId, {
|
|
189
|
+
titlebar: !fancy,
|
|
190
|
+
'can-resize': !fancy,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Resume on incoming update
|
|
196
|
+
if (type === 'backend/update' && suspended) {
|
|
197
|
+
// Signal renderer that we have resumed
|
|
198
|
+
resumeRenderer();
|
|
199
|
+
// Setup drag
|
|
200
|
+
setupDrag();
|
|
201
|
+
// We schedule this for the next tick here because resizing and unhiding
|
|
202
|
+
// during the same tick will flash with a white background.
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
perf.mark('resume/start');
|
|
205
|
+
// Doublecheck if we are not re-suspended.
|
|
206
|
+
const { suspended } = selectBackend(store.getState());
|
|
207
|
+
if (suspended) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
Byond.winset(Byond.windowId, {
|
|
211
|
+
'is-visible': true,
|
|
212
|
+
});
|
|
213
|
+
perf.mark('resume/finish');
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return next(action);
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Sends an action to `ui_act` on `src_object` that this tgui window
|
|
223
|
+
* is associated with.
|
|
224
|
+
*/
|
|
225
|
+
export const sendAct = (action: string, payload: object = {}) => {
|
|
226
|
+
// Validate that payload is an object
|
|
227
|
+
// prettier-ignore
|
|
228
|
+
const isObject = typeof payload === 'object'
|
|
229
|
+
&& payload !== null
|
|
230
|
+
&& !Array.isArray(payload);
|
|
231
|
+
if (!isObject) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
Byond.sendMessage('act/' + action, payload);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
type BackendState<TData> = {
|
|
238
|
+
config: {
|
|
239
|
+
title: string;
|
|
240
|
+
status: number;
|
|
241
|
+
interface: string;
|
|
242
|
+
refreshing: boolean;
|
|
243
|
+
window: {
|
|
244
|
+
key: string;
|
|
245
|
+
size: [number, number];
|
|
246
|
+
fancy: boolean;
|
|
247
|
+
locked: boolean;
|
|
248
|
+
};
|
|
249
|
+
client: {
|
|
250
|
+
ckey: string;
|
|
251
|
+
address: string;
|
|
252
|
+
computer_id: string;
|
|
253
|
+
};
|
|
254
|
+
user: {
|
|
255
|
+
name: string;
|
|
256
|
+
observer: number;
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
data: TData;
|
|
260
|
+
shared: Record<string, any>;
|
|
261
|
+
suspending: boolean;
|
|
262
|
+
suspended: boolean;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Selects a backend-related slice of Redux state
|
|
267
|
+
*/
|
|
268
|
+
export const selectBackend = <TData>(state: any): BackendState<TData> =>
|
|
269
|
+
state.backend || {};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get data from tgui backend.
|
|
273
|
+
*
|
|
274
|
+
* Includes the `act` function for performing DM actions.
|
|
275
|
+
*/
|
|
276
|
+
export const useBackend = <TData>() => {
|
|
277
|
+
const state: BackendState<TData> = globalStore?.getState()?.backend;
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
...state,
|
|
281
|
+
act: sendAct,
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A tuple that contains the state and a setter function for it.
|
|
287
|
+
*/
|
|
288
|
+
type StateWithSetter<T> = [T, (nextState: T) => void];
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Allocates state on Redux store without sharing it with other clients.
|
|
292
|
+
*
|
|
293
|
+
* Use it when you want to have a stateful variable in your component
|
|
294
|
+
* that persists between renders, but will be forgotten after you close
|
|
295
|
+
* the UI.
|
|
296
|
+
*
|
|
297
|
+
* It is a lot more performant than `setSharedState`.
|
|
298
|
+
*
|
|
299
|
+
* @param context React context.
|
|
300
|
+
* @param key Key which uniquely identifies this state in Redux store.
|
|
301
|
+
* @param initialState Initializes your global variable with this value.
|
|
302
|
+
* @deprecated Use useState and useEffect when you can. Pass the state as a prop.
|
|
303
|
+
*/
|
|
304
|
+
export const useLocalState = <T>(
|
|
305
|
+
key: string,
|
|
306
|
+
initialState: T
|
|
307
|
+
): StateWithSetter<T> => {
|
|
308
|
+
const state = globalStore?.getState()?.backend;
|
|
309
|
+
const sharedStates = state?.shared ?? {};
|
|
310
|
+
const sharedState = key in sharedStates ? sharedStates[key] : initialState;
|
|
311
|
+
return [
|
|
312
|
+
sharedState,
|
|
313
|
+
(nextState) => {
|
|
314
|
+
globalStore.dispatch(
|
|
315
|
+
backendSetSharedState({
|
|
316
|
+
key,
|
|
317
|
+
nextState:
|
|
318
|
+
typeof nextState === 'function'
|
|
319
|
+
? nextState(sharedState)
|
|
320
|
+
: nextState,
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Allocates state on Redux store, and **shares** it with other clients
|
|
329
|
+
* in the game.
|
|
330
|
+
*
|
|
331
|
+
* Use it when you want to have a stateful variable in your component
|
|
332
|
+
* that persists not only between renders, but also gets pushed to other
|
|
333
|
+
* clients that observe this UI.
|
|
334
|
+
*
|
|
335
|
+
* This makes creation of observable s
|
|
336
|
+
*
|
|
337
|
+
* @param context React context.
|
|
338
|
+
* @param key Key which uniquely identifies this state in Redux store.
|
|
339
|
+
* @param initialState Initializes your global variable with this value.
|
|
340
|
+
*/
|
|
341
|
+
export const useSharedState = <T>(
|
|
342
|
+
key: string,
|
|
343
|
+
initialState: T
|
|
344
|
+
): StateWithSetter<T> => {
|
|
345
|
+
const state = globalStore?.getState()?.backend;
|
|
346
|
+
const sharedStates = state?.shared ?? {};
|
|
347
|
+
const sharedState = key in sharedStates ? sharedStates[key] : initialState;
|
|
348
|
+
return [
|
|
349
|
+
sharedState,
|
|
350
|
+
(nextState) => {
|
|
351
|
+
Byond.sendMessage({
|
|
352
|
+
type: 'setSharedState',
|
|
353
|
+
key,
|
|
354
|
+
value:
|
|
355
|
+
JSON.stringify(
|
|
356
|
+
typeof nextState === 'function' ? nextState(sharedState) : nextState
|
|
357
|
+
) || '',
|
|
358
|
+
});
|
|
359
|
+
},
|
|
360
|
+
];
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export const useDispatch = () => {
|
|
364
|
+
return globalStore.dispatch;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
export const useSelector = (selector: (state: any) => any) => {
|
|
368
|
+
return selector(globalStore?.getState());
|
|
369
|
+
};
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file
|
|
3
|
+
* @copyright 2020 Aleksej Komarov
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Iterates over elements of collection, returning an array of all elements
|
|
9
|
+
* iteratee returns truthy for. The predicate is invoked with three
|
|
10
|
+
* arguments: (value, index|key, collection).
|
|
11
|
+
*
|
|
12
|
+
* If collection is 'null' or 'undefined', it will be returned "as is"
|
|
13
|
+
* without emitting any errors (which can be useful in some cases).
|
|
14
|
+
*/
|
|
15
|
+
export const filter = <T>(
|
|
16
|
+
collection: T[],
|
|
17
|
+
iterateeFn: (input: T, index: number, collection: T[]) => boolean
|
|
18
|
+
): T[] => {
|
|
19
|
+
if (collection === null || collection === undefined) {
|
|
20
|
+
return collection;
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(collection)) {
|
|
23
|
+
const result: T[] = [];
|
|
24
|
+
for (let i = 0; i < collection.length; i++) {
|
|
25
|
+
const item = collection[i];
|
|
26
|
+
if (iterateeFn(item, i, collection)) {
|
|
27
|
+
result.push(item);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`filter() can't iterate on type ${typeof collection}`);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type MapFunction = {
|
|
36
|
+
<T, U>(
|
|
37
|
+
collection: T[],
|
|
38
|
+
iterateeFn: (value: T, index: number, collection: T[]) => U
|
|
39
|
+
): U[];
|
|
40
|
+
|
|
41
|
+
<T, U, K extends string | number>(
|
|
42
|
+
collection: Record<K, T>,
|
|
43
|
+
iterateeFn: (value: T, index: K, collection: Record<K, T>) => U
|
|
44
|
+
): U[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates an array of values by running each element in collection
|
|
49
|
+
* thru an iteratee function. The iteratee is invoked with three
|
|
50
|
+
* arguments: (value, index|key, collection).
|
|
51
|
+
*
|
|
52
|
+
* If collection is 'null' or 'undefined', it will be returned "as is"
|
|
53
|
+
* without emitting any errors (which can be useful in some cases).
|
|
54
|
+
*/
|
|
55
|
+
export const map: MapFunction = (collection, iterateeFn) => {
|
|
56
|
+
if (collection === null || collection === undefined) {
|
|
57
|
+
return collection;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(collection)) {
|
|
61
|
+
const result: unknown[] = [];
|
|
62
|
+
for (let i = 0; i < collection.length; i++) {
|
|
63
|
+
result.push(iterateeFn(collection[i], i, collection));
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (typeof collection === 'object') {
|
|
69
|
+
const result: unknown[] = [];
|
|
70
|
+
for (let i in collection) {
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(collection, i)) {
|
|
72
|
+
result.push(iterateeFn(collection[i], i, collection));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new Error(`map() can't iterate on type ${typeof collection}`);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const COMPARATOR = (objA, objB) => {
|
|
82
|
+
const criteriaA = objA.criteria;
|
|
83
|
+
const criteriaB = objB.criteria;
|
|
84
|
+
const length = criteriaA.length;
|
|
85
|
+
for (let i = 0; i < length; i++) {
|
|
86
|
+
const a = criteriaA[i];
|
|
87
|
+
const b = criteriaB[i];
|
|
88
|
+
if (a < b) {
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
if (a > b) {
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates an array of elements, sorted in ascending order by the results
|
|
100
|
+
* of running each element in a collection thru each iteratee.
|
|
101
|
+
*
|
|
102
|
+
* Iteratees are called with one argument (value).
|
|
103
|
+
*/
|
|
104
|
+
export const sortBy = <T>(
|
|
105
|
+
array: T[],
|
|
106
|
+
...iterateeFns: ((input: T) => unknown)[]
|
|
107
|
+
): T[] => {
|
|
108
|
+
if (!Array.isArray(array)) {
|
|
109
|
+
return array;
|
|
110
|
+
}
|
|
111
|
+
let length = array.length;
|
|
112
|
+
// Iterate over the array to collect criteria to sort it by
|
|
113
|
+
let mappedArray: {
|
|
114
|
+
criteria: unknown[];
|
|
115
|
+
value: T;
|
|
116
|
+
}[] = [];
|
|
117
|
+
for (let i = 0; i < length; i++) {
|
|
118
|
+
const value = array[i];
|
|
119
|
+
mappedArray.push({
|
|
120
|
+
criteria: iterateeFns.map((fn) => fn(value)),
|
|
121
|
+
value,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Sort criteria using the base comparator
|
|
125
|
+
mappedArray.sort(COMPARATOR);
|
|
126
|
+
|
|
127
|
+
// Unwrap values
|
|
128
|
+
const values: T[] = [];
|
|
129
|
+
while (length--) {
|
|
130
|
+
values[length] = mappedArray[length].value;
|
|
131
|
+
}
|
|
132
|
+
return values;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const sort = <T>(array: T[]): T[] => sortBy(array);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns a range of numbers from start to end, exclusively.
|
|
139
|
+
* For example, range(0, 5) will return [0, 1, 2, 3, 4].
|
|
140
|
+
*/
|
|
141
|
+
export const range = (start: number, end: number): number[] =>
|
|
142
|
+
new Array(end - start).fill(null).map((_, index) => index + start);
|
|
143
|
+
|
|
144
|
+
type ReduceFunction = {
|
|
145
|
+
<T, U>(
|
|
146
|
+
array: T[],
|
|
147
|
+
reducerFn: (
|
|
148
|
+
accumulator: U,
|
|
149
|
+
currentValue: T,
|
|
150
|
+
currentIndex: number,
|
|
151
|
+
array: T[]
|
|
152
|
+
) => U,
|
|
153
|
+
initialValue: U
|
|
154
|
+
): U;
|
|
155
|
+
<T>(
|
|
156
|
+
array: T[],
|
|
157
|
+
reducerFn: (
|
|
158
|
+
accumulator: T,
|
|
159
|
+
currentValue: T,
|
|
160
|
+
currentIndex: number,
|
|
161
|
+
array: T[]
|
|
162
|
+
) => T
|
|
163
|
+
): T;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A fast implementation of reduce.
|
|
168
|
+
*/
|
|
169
|
+
export const reduce: ReduceFunction = (array, reducerFn, initialValue?) => {
|
|
170
|
+
const length = array.length;
|
|
171
|
+
let i: number;
|
|
172
|
+
let result;
|
|
173
|
+
if (initialValue === undefined) {
|
|
174
|
+
i = 1;
|
|
175
|
+
result = array[0];
|
|
176
|
+
} else {
|
|
177
|
+
i = 0;
|
|
178
|
+
result = initialValue;
|
|
179
|
+
}
|
|
180
|
+
for (; i < length; i++) {
|
|
181
|
+
result = reducerFn(result, array[i], i, array);
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Creates a duplicate-free version of an array, using SameValueZero for
|
|
188
|
+
* equality comparisons, in which only the first occurrence of each element
|
|
189
|
+
* is kept. The order of result values is determined by the order they occur
|
|
190
|
+
* in the array.
|
|
191
|
+
*
|
|
192
|
+
* It accepts iteratee which is invoked for each element in array to generate
|
|
193
|
+
* the criterion by which uniqueness is computed. The order of result values
|
|
194
|
+
* is determined by the order they occur in the array. The iteratee is
|
|
195
|
+
* invoked with one argument: value.
|
|
196
|
+
*/
|
|
197
|
+
export const uniqBy = <T extends unknown>(
|
|
198
|
+
array: T[],
|
|
199
|
+
iterateeFn?: (value: T) => unknown
|
|
200
|
+
): T[] => {
|
|
201
|
+
const { length } = array;
|
|
202
|
+
const result: T[] = [];
|
|
203
|
+
const seen: unknown[] = iterateeFn ? [] : result;
|
|
204
|
+
let index = -1;
|
|
205
|
+
|
|
206
|
+
outer: while (++index < length) {
|
|
207
|
+
let value: T | 0 = array[index];
|
|
208
|
+
const computed = iterateeFn ? iterateeFn(value) : value;
|
|
209
|
+
if (computed === computed) {
|
|
210
|
+
let seenIndex = seen.length;
|
|
211
|
+
while (seenIndex--) {
|
|
212
|
+
if (seen[seenIndex] === computed) {
|
|
213
|
+
continue outer;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (iterateeFn) {
|
|
217
|
+
seen.push(computed);
|
|
218
|
+
}
|
|
219
|
+
result.push(value);
|
|
220
|
+
} else if (!seen.includes(computed)) {
|
|
221
|
+
if (seen !== result) {
|
|
222
|
+
seen.push(computed);
|
|
223
|
+
}
|
|
224
|
+
result.push(value);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const uniq = <T>(array: T[]): T[] => uniqBy(array);
|
|
231
|
+
|
|
232
|
+
type Zip<T extends unknown[][]> = {
|
|
233
|
+
[I in keyof T]: T[I] extends (infer U)[] ? U : never;
|
|
234
|
+
}[];
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Creates an array of grouped elements, the first of which contains
|
|
238
|
+
* the first elements of the given arrays, the second of which contains
|
|
239
|
+
* the second elements of the given arrays, and so on.
|
|
240
|
+
*/
|
|
241
|
+
export const zip = <T extends unknown[][]>(...arrays: T): Zip<T> => {
|
|
242
|
+
if (arrays.length === 0) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const numArrays = arrays.length;
|
|
246
|
+
const numValues = arrays[0].length;
|
|
247
|
+
const result: Zip<T> = [];
|
|
248
|
+
for (let valueIndex = 0; valueIndex < numValues; valueIndex++) {
|
|
249
|
+
const entry: unknown[] = [];
|
|
250
|
+
for (let arrayIndex = 0; arrayIndex < numArrays; arrayIndex++) {
|
|
251
|
+
entry.push(arrays[arrayIndex][valueIndex]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// I tried everything to remove this any, and have no idea how to do it.
|
|
255
|
+
result.push(entry as any);
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const binarySearch = <T, U = unknown>(
|
|
261
|
+
getKey: (value: T) => U,
|
|
262
|
+
collection: readonly T[],
|
|
263
|
+
inserting: T
|
|
264
|
+
): number => {
|
|
265
|
+
if (collection.length === 0) {
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const insertingKey = getKey(inserting);
|
|
270
|
+
|
|
271
|
+
let [low, high] = [0, collection.length];
|
|
272
|
+
|
|
273
|
+
// Because we have checked if the collection is empty, it's impossible
|
|
274
|
+
// for this to be used before assignment.
|
|
275
|
+
let compare: U = undefined as unknown as U;
|
|
276
|
+
let middle = 0;
|
|
277
|
+
|
|
278
|
+
while (low < high) {
|
|
279
|
+
middle = (low + high) >> 1;
|
|
280
|
+
|
|
281
|
+
compare = getKey(collection[middle]);
|
|
282
|
+
|
|
283
|
+
if (compare < insertingKey) {
|
|
284
|
+
low = middle + 1;
|
|
285
|
+
} else if (compare === insertingKey) {
|
|
286
|
+
return middle;
|
|
287
|
+
} else {
|
|
288
|
+
high = middle;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return compare > insertingKey ? middle : middle + 1;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const binaryInsertWith = <T, U = unknown>(
|
|
296
|
+
collection: readonly T[],
|
|
297
|
+
value: T,
|
|
298
|
+
getKey: (value: T) => U
|
|
299
|
+
): T[] => {
|
|
300
|
+
const copy = [...collection];
|
|
301
|
+
copy.splice(binarySearch(getKey, collection, value), 0, value);
|
|
302
|
+
return copy;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* This method takes a collection of items and a number, returning a collection
|
|
307
|
+
* of collections, where the maximum amount of items in each is that second arg
|
|
308
|
+
*/
|
|
309
|
+
export const paginate = <T>(collection: T[], maxPerPage: number): T[][] => {
|
|
310
|
+
const pages: T[][] = [];
|
|
311
|
+
let page: T[] = [];
|
|
312
|
+
let itemsToAdd = maxPerPage;
|
|
313
|
+
|
|
314
|
+
for (const item of collection) {
|
|
315
|
+
page.push(item);
|
|
316
|
+
itemsToAdd--;
|
|
317
|
+
if (!itemsToAdd) {
|
|
318
|
+
itemsToAdd = maxPerPage;
|
|
319
|
+
pages.push(page);
|
|
320
|
+
page = [];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (page.length) {
|
|
324
|
+
pages.push(page);
|
|
325
|
+
}
|
|
326
|
+
return pages;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const isObject = (obj: unknown): obj is object =>
|
|
330
|
+
typeof obj === 'object' && obj !== null;
|
|
331
|
+
|
|
332
|
+
// Does a deep merge of two objects. DO NOT FEED CIRCULAR OBJECTS!!
|
|
333
|
+
export const deepMerge = (...objects: any[]): any => {
|
|
334
|
+
const target = {};
|
|
335
|
+
for (const object of objects) {
|
|
336
|
+
for (const key of Object.keys(object)) {
|
|
337
|
+
const targetValue = target[key];
|
|
338
|
+
const objectValue = object[key];
|
|
339
|
+
if (Array.isArray(targetValue) && Array.isArray(objectValue)) {
|
|
340
|
+
target[key] = [...targetValue, ...objectValue];
|
|
341
|
+
} else if (isObject(targetValue) && isObject(objectValue)) {
|
|
342
|
+
target[key] = deepMerge(targetValue, objectValue);
|
|
343
|
+
} else {
|
|
344
|
+
target[key] = objectValue;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return target;
|
|
349
|
+
};
|