From e27bc2eda33e0116ab28781c457a5be15ec8175f Mon Sep 17 00:00:00 2001 From: Letter N <24603524+LetterN@users.noreply.github.com> Date: Tue, 21 Jul 2020 13:32:12 +0800 Subject: [PATCH 001/134] docs and things --- tgui/README.md | 7 +- tgui/docs/component-reference.md | 22 +- tgui/docs/tutorial-and-examples.md | 4 +- tgui/yarn.lock | 566 ++++++++++++++++------------- 4 files changed, 337 insertions(+), 262 deletions(-) diff --git a/tgui/README.md b/tgui/README.md index 5ddeb18fdd..c479324764 100644 --- a/tgui/README.md +++ b/tgui/README.md @@ -180,8 +180,11 @@ See: [Component Reference](docs/component-reference.md). ## License -All code is licensed with the parent license of *tgstation*, **AGPL-3.0**. +Source code is covered by /tg/station's parent license - **AGPL-3.0** +(see the main [README](../README.md)), unless otherwise indicated. -See the main [README](../README.md) for more details. +Some files are annotated with a copyright header, which explicitly states +the copyright holder and license of the file. Most of the core tgui +source code is available under the **MIT** license. The Authors retain all copyright to their respective work here submitted. diff --git a/tgui/docs/component-reference.md b/tgui/docs/component-reference.md index a2a0066a70..e1f7ebf9b4 100644 --- a/tgui/docs/component-reference.md +++ b/tgui/docs/component-reference.md @@ -30,6 +30,8 @@ Make sure to add new items to this list if you document new components. - [`Icon`](#icon) - [`Input`](#input) - [`Knob`](#knob) + - [`LabeledControls`](#labeledcontrols) + - [`LabeledControls.Item`](#labeledcontrolsitem) - [`LabeledList`](#labeledlist) - [`LabeledList.Item`](#labeledlistitem) - [`LabeledList.Divider`](#labeledlistdivider) @@ -239,7 +241,7 @@ A ghetto checkbox, made entirely using existing Button API. ### `Button.Confirm` -A button with a an extra confirmation step, using native button component. +A button with an extra confirmation step, using native button component. **Props:** @@ -584,6 +586,24 @@ the input, or successfully enter a number. - `onDrag: (e, value) => void` - An event, which fires about every 500ms when you drag the input up and down, on release and on manual editing. +### `LabeledControls` + +LabeledControls is a horizontal grid, that is designed to hold various +controls, like [Knobs](#knob) or small [Buttons](#button). Every item in +this grid is labeled at the bottom. + +**Props:** + +- See inherited props: [Box](#box) +- `children: LabeledControls.Item` - Items to render. + +### `LabeledControls.Item` + +**Props:** + +- See inherited props: [Box](#box) +- `label: string` - Item label. + ### `LabeledList` LabeledList is a continuous, vertical list of text and other content, where diff --git a/tgui/docs/tutorial-and-examples.md b/tgui/docs/tutorial-and-examples.md index 683f819a8c..3497ea9746 100644 --- a/tgui/docs/tutorial-and-examples.md +++ b/tgui/docs/tutorial-and-examples.md @@ -142,7 +142,7 @@ export const SampleInterface = (props, context) => { + ); + })} + { !!admin_controls && ( + + + + + + + + )} + +
+ + {!!players && players.map(player => { return ( + + {player.votes !== undefined + && (Votes : {player.votes} )} + { + !!player.actions && player.actions.map(action => { + return ( + ); }) + } + ); + })} + +
+
+ + {!!all_roles && all_roles.map(r => ( + + + {r} + + + ))} +
+
+ + + ); +}; diff --git a/tgui/packages/tgui/interfaces/NtosCyborgRemoteMonitorSyndicate.js b/tgui/packages/tgui/interfaces/NtosCyborgRemoteMonitorSyndicate.js new file mode 100644 index 0000000000..07a0038544 --- /dev/null +++ b/tgui/packages/tgui/interfaces/NtosCyborgRemoteMonitorSyndicate.js @@ -0,0 +1,15 @@ +import { Fragment } from 'inferno'; +import { useBackend } from '../backend'; +import { Box, Button, LabeledList, NoticeBox, Section } from '../components'; +import { NtosWindow } from '../layouts'; +import { NtosCyborgRemoteMonitorContent } from './NtosCyborgRemoteMonitor'; + +export const NtosCyborgRemoteMonitorSyndicate = (props, context) => { + return ( + + + + + + ); +}; diff --git a/tgui/packages/tgui/interfaces/SeedExtractor.js b/tgui/packages/tgui/interfaces/SeedExtractor.js new file mode 100644 index 0000000000..3dbdd6da06 --- /dev/null +++ b/tgui/packages/tgui/interfaces/SeedExtractor.js @@ -0,0 +1,89 @@ +import { sortBy } from 'common/collections'; +import { flow } from 'common/fp'; +import { toTitleCase } from 'common/string'; +import { useBackend } from '../backend'; +import { Button, Section, Table } from '../components'; +import { Window } from '../layouts'; + +/** + * This method takes a seed string and splits the values + * into an object + */ +const splitSeedString = text => { + const re = /([^;=]+)=([^;]+)/g; + const ret = {}; + let m; + do { + m = re.exec(text); + if (m) { + ret[m[1]] = m[2] + ''; + } + } while (m); + return ret; +}; + +/** + * This method splits up the string "name" we get for the seeds + * and creates an object from it include the value that is the + * ammount + * + * @returns {any[]} + */ +const createSeeds = seedStrings => { + const objs = Object.keys(seedStrings).map(key => { + const obj = splitSeedString(key); + obj.amount = seedStrings[key]; + obj.key = key; + obj.name = toTitleCase(obj.name.replace('pack of ', '')); + return obj; + }); + return flow([ + sortBy(item => item.name), + ])(objs); +}; + +export const SeedExtractor = (props, context) => { + const { act, data } = useBackend(context); + const seeds = createSeeds(data.seeds); + return ( + + +
+ + + Name + Lifespan + Endurance + Maturation + Production + Yield + Potency + Instability + Stock + + {seeds.map(item => ( + + {item.name} + {item.lifespan} + {item.endurance} + {item.maturation} + {item.production} + {item.yield} + {item.potency} + {item.instability} + +
+
+
+
+ ); +}; From 775d84370f9dbb74b81f10de417f265404a96244 Mon Sep 17 00:00:00 2001 From: Letter N <24603524+LetterN@users.noreply.github.com> Date: Tue, 21 Jul 2020 15:03:15 +0800 Subject: [PATCH 008/134] huh?? --- tgui/packages/tgui/interfaces/Apc.js | 24 ------------------- .../tgui/interfaces/NaniteCloudControl.js | 12 +++++----- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/tgui/packages/tgui/interfaces/Apc.js b/tgui/packages/tgui/interfaces/Apc.js index 0f44e3509d..72cee0bf83 100644 --- a/tgui/packages/tgui/interfaces/Apc.js +++ b/tgui/packages/tgui/interfaces/Apc.js @@ -206,33 +206,9 @@ const ApcContent = (props, context) => { + ))} + + + + + + + + + + ); +}; + +const KitchenSinkButton = props => { + return ( +
+ +
+ ); +}; + +const KitchenSinkBox = props => { + return ( +
+ + bold + + + italic + + + opacity 0.5 + + + opacity 0.25 + + + m: 2 + + + left + + + center + + + right + +
+ ); +}; + +const KitchenSinkFlexAndSections = (props, context) => { + const [grow, setGrow] = useLocalState( + context, 'fs_grow', 1); + const [direction, setDirection] = useLocalState( + context, 'fs_direction', 'column'); + const [fill, setFill] = useLocalState( + context, 'fs_fill', true); + const [hasTitle, setHasTitle] = useLocalState( + context, 'fs_title', true); + return ( + + +
+ + + + +
+
+ + + +
+ Content +
+
+ +
+ Content +
+
+
+
+
+ ); +}; + +const KitchenSinkProgressBar = (props, context) => { + const [ + progress, + setProgress, + ] = useLocalState(context, 'progress', 0.5); + return ( +
+ + Value: {Number(progress).toFixed(1)} + + +
+ ); +}; + +const KitchenSinkTabs = (props, context) => { + const [tabIndex, setTabIndex] = useLocalState(context, 'tabIndex', 0); + const [vertical, setVertical] = useLocalState(context, 'tabVert'); + const [altSelection, setAltSelection] = useLocalState(context, 'tabAlt'); + const TAB_RANGE = [1, 2, 3, 4, 5]; + return ( +
+ + setVertical(!vertical)} /> + setAltSelection(!altSelection)} /> + + + {TAB_RANGE.map((number, i) => ( + setTabIndex(i)}> + Tab #{number} + + ))} + +
+ ); +}; + +const KitchenSinkTooltip = props => { + const positions = [ + 'top', + 'left', + 'right', + 'bottom', + 'bottom-left', + 'bottom-right', + ]; + return ( +
+ + + Box (hover me). + + +
+ ); +}; + +const KitchenSinkInput = (props, context) => { + const [ + number, + setNumber, + ] = useLocalState(context, 'number', 0); + const [ + text, + setText, + ] = useLocalState(context, 'text', "Sample text"); + return ( +
+ + + setText(value)} /> + + + setText(value)} /> + + + setNumber(value)} /> + + + setNumber(value)} /> + + + setNumber(value)} /> + + + setNumber(value)} /> + setNumber(value)} /> + + + + setNumber(value)}> + {control => ( + + + {control.inputElement} + + )} + + + + +
+ ); +}; + +const KitchenSinkCollapsible = props => { + return ( +
+ + )}> + + +
+ ); +}; + +const BoxWithSampleText = props => { + return ( + + + Jackdaws love my big sphinx of quartz. + + + The wide electrification of the southern + provinces will give a powerful impetus to the + growth of agriculture. + + + ); +}; + +const KitchenSinkBlockQuote = props => { + return ( +
+
+ +
+
+ ); +}; + +const KitchenSinkByondUi = (props, context) => { + const { config } = useBackend(context); + const [code, setCode] = useLocalState(context, + 'byondUiEvalCode', + `Byond.winset('${window.__windowId__}', {\n 'is-visible': true,\n})`); + return ( + +
+ +
+
setImmediate(() => { + try { + const result = new Function('return (' + code + ')')(); + if (result && result.then) { + logger.log('Promise'); + result.then(logger.log); + } + else { + logger.log(result); + } + } + catch (err) { + logger.log(err); + } + })}> + Evaluate + + )}> + setCode(e.target.value)}> + {code} + +
+
+ ); +}; + +const KitchenSinkThemes = (props, context) => { + const [theme, setTheme] = useLocalState(context, 'kitchenSinkTheme'); + return ( +
+ + + setTheme(value)} /> + + +
+ ); +}; + +const KitchenSinkStorage = (props, context) => { + if (!window.localStorage) { + return ( + + Local storage is not available. + + ); + } + return ( +
{ + localStorage.clear(); + }}> + Clear + + )}> + + + {localStorage.length} + + + {formatSiUnit(localStorage.remainingSpace, 0, 'B')} + + +
+ ); +}; diff --git a/tgui/packages/tgui/debug/index.js b/tgui/packages/tgui/debug/index.js new file mode 100644 index 0000000000..83fc136548 --- /dev/null +++ b/tgui/packages/tgui/debug/index.js @@ -0,0 +1,49 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { subscribeToHotKey } from '../hotkeys'; + +export const toggleKitchenSink = () => ({ + type: 'debug/toggleKitchenSink', +}); + +export const toggleDebugLayout = () => ({ + type: 'debug/toggleDebugLayout', +}); + +subscribeToHotKey('F11', () => toggleDebugLayout()); +subscribeToHotKey('F12', () => toggleKitchenSink()); +subscribeToHotKey('Ctrl+Alt+[8]', () => { + // NOTE: We need to call this in a timeout, because we need a clean + // stack in order for this to be a fatal error. + setTimeout(() => { + throw new Error( + 'OOPSIE WOOPSIE!! UwU We made a fucky wucky!! A wittle' + + ' fucko boingo! The code monkeys at our headquarters are' + + ' working VEWY HAWD to fix this!'); + }); +}); + +export const selectDebug = state => state.debug; + +export const useDebug = context => selectDebug(context.store.getState()); + +export const debugReducer = (state = {}, action) => { + const { type, payload } = action; + if (type === 'debug/toggleKitchenSink') { + return { + ...state, + kitchenSink: !state.kitchenSink, + }; + } + if (type === 'debug/toggleDebugLayout') { + return { + ...state, + debugLayout: !state.debugLayout, + }; + } + return state; +}; diff --git a/tgui/packages/tgui/drag.js b/tgui/packages/tgui/drag.js index 1b735cb818..99db716410 100644 --- a/tgui/packages/tgui/drag.js +++ b/tgui/packages/tgui/drag.js @@ -4,79 +4,171 @@ * @license MIT */ -import { vecAdd, vecInverse, vecMultiply } from 'common/vector'; -import { winget, winset } from './byond'; +import { storage } from 'common/storage'; +import { vecAdd, vecInverse, vecMultiply, vecScale } from 'common/vector'; import { createLogger } from './logging'; const logger = createLogger('drag'); -let ref; +let windowKey = window.__windowId__; let dragging = false; let resizing = false; let screenOffset = [0, 0]; +let screenOffsetPromise; let dragPointOffset; let resizeMatrix; let initialSize; let size; -const getWindowPosition = ref => { - return winget(ref, 'pos').then(pos => [pos.x, pos.y]); +export const setWindowKey = key => { + windowKey = key; }; -const setWindowPosition = (ref, vec) => { - return winset(ref, 'pos', vec[0] + ',' + vec[1]); +export const getWindowPosition = () => [ + window.screenLeft, + window.screenTop, +]; + +export const getWindowSize = () => [ + window.innerWidth, + window.innerHeight, +]; + +export const setWindowPosition = vec => { + const byondPos = vecAdd(vec, screenOffset); + return Byond.winset(window.__windowId__, { + pos: byondPos[0] + ',' + byondPos[1], + }); }; -const setWindowSize = (ref, vec) => { - return winset(ref, 'size', vec[0] + ',' + vec[1]); +export const setWindowSize = vec => { + return Byond.winset(window.__windowId__, { + size: vec[0] + 'x' + vec[1], + }); }; -export const setupDrag = async state => { - logger.log('setting up'); - ref = state.config.window; - // Calculate offset caused by windows taskbar - const realPosition = await getWindowPosition(ref); - screenOffset = [ - realPosition[0] - window.screenLeft, - realPosition[1] - window.screenTop, - ]; - // Constraint window position - const [relocated, safePosition] = constraintPosition(realPosition); - if (relocated) { - setWindowPosition(ref, safePosition); +export const getScreenPosition = () => [ + 0 - screenOffset[0], + 0 - screenOffset[1], +]; + +export const getScreenSize = () => [ + window.screen.availWidth, + window.screen.availHeight, +]; + +/** + * Moves an item to the top of the recents array, and keeps its length + * limited to the number in `limit` argument. + * + * Uses a strict equality check for comparisons. + * + * Returns new recents and an item which was trimmed. + */ +const touchRecents = (recents, touchedItem, limit = 50) => { + const nextRecents = [touchedItem]; + let trimmedItem; + for (let i = 0; i < recents.length; i++) { + const item = recents[i]; + if (item === touchedItem) { + continue; + } + if (nextRecents.length < limit) { + nextRecents.push(item); + } + else { + trimmedItem = item; + } } - logger.debug('current state', { ref, screenOffset }); + return [nextRecents, trimmedItem]; +}; + +export const storeWindowGeometry = windowKey => { + logger.log('storing geometry'); + const geometry = { + pos: getWindowPosition(), + size: getWindowSize(), + }; + storage.set(windowKey, geometry); + // Update the list of stored geometries + const [geometries, trimmedKey] = touchRecents( + storage.get('geometries') || [], + windowKey); + if (trimmedKey) { + storage.remove(trimmedKey); + } + storage.set('geometries', geometries); +}; + +export const recallWindowGeometry = async (windowKey, options = {}) => { + // Only recall geometry in fancy mode + const geometry = options.fancy && storage.get(windowKey); + if (geometry) { + logger.log('recalled geometry:', geometry); + } + let pos = geometry?.pos || options.pos; + const size = options.size; + // Set window size + if (size) { + setWindowSize(size); + } + // Set window position + if (pos) { + await screenOffsetPromise; + // Constraint window position if monitor lock was set in preferences. + if (size && options.locked) { + pos = constraintPosition(pos, size)[1]; + } + setWindowPosition(pos); + } + // Set window position at the center of the screen. + else if (size) { + await screenOffsetPromise; + const areaAvailable = [ + window.screen.availWidth - Math.abs(screenOffset[0]), + window.screen.availHeight - Math.abs(screenOffset[1]), + ]; + const pos = vecAdd( + vecScale(areaAvailable, 0.5), + vecScale(size, -0.5), + vecScale(screenOffset, -1.0)); + setWindowPosition(pos); + } +}; + +export const setupDrag = async () => { + // Calculate screen offset caused by the windows taskbar + screenOffsetPromise = Byond.winget(window.__windowId__, 'pos') + .then(pos => [ + pos.x - window.screenLeft, + pos.y - window.screenTop, + ]); + screenOffset = await screenOffsetPromise; + logger.debug('screen offset', screenOffset); }; /** * Constraints window position to safe screen area, accounting for safe * margins which could be a system taskbar. */ -const constraintPosition = position => { - let x = position[0]; - let y = position[1]; +const constraintPosition = (pos, size) => { + const screenPos = getScreenPosition(); + const screenSize = getScreenSize(); + const nextPos = [pos[0], pos[1]]; let relocated = false; - // Left - if (x < 0) { - x = 0; - relocated = true; + for (let i = 0; i < 2; i++) { + const leftBoundary = screenPos[i]; + const rightBoundary = screenPos[i] + screenSize[i]; + if (pos[i] < leftBoundary) { + nextPos[i] = leftBoundary; + relocated = true; + } + else if (pos[i] + size[i] > rightBoundary) { + nextPos[i] = rightBoundary - size[i]; + relocated = true; + } } - // Right - else if (x + window.innerWidth > window.screen.availWidth) { - x = window.screen.availWidth - window.innerWidth; - relocated = true; - } - // Top - if (y < 0) { - y = 0; - relocated = true; - } - // Bottom - else if (y + window.innerHeight > window.screen.availHeight) { - y = window.screen.availHeight - window.innerHeight; - relocated = true; - } - return [relocated, [x, y]]; + return [relocated, nextPos]; }; export const dragStartHandler = event => { @@ -97,6 +189,7 @@ const dragEndHandler = event => { document.removeEventListener('mousemove', dragMoveHandler); document.removeEventListener('mouseup', dragEndHandler); dragging = false; + storeWindowGeometry(windowKey); }; const dragMoveHandler = event => { @@ -104,9 +197,8 @@ const dragMoveHandler = event => { return; } event.preventDefault(); - setWindowPosition(ref, vecAdd( + setWindowPosition(vecAdd( [event.screenX, event.screenY], - screenOffset, dragPointOffset)); }; @@ -133,6 +225,7 @@ const resizeEndHandler = event => { document.removeEventListener('mousemove', resizeMoveHandler); document.removeEventListener('mouseup', resizeEndHandler); resizing = false; + storeWindowGeometry(windowKey); }; const resizeMoveHandler = event => { @@ -146,7 +239,7 @@ const resizeMoveHandler = event => { dragPointOffset, [1, 1]))); // Sane window size values - size[0] = Math.max(size[0], 250); - size[1] = Math.max(size[1], 120); - setWindowSize(ref, size); + size[0] = Math.max(size[0], 150); + size[1] = Math.max(size[1], 50); + setWindowSize(size); }; diff --git a/tgui/packages/tgui/format.js b/tgui/packages/tgui/format.js index 4a72658723..1f8420860e 100644 --- a/tgui/packages/tgui/format.js +++ b/tgui/packages/tgui/format.js @@ -39,6 +39,9 @@ export const formatSiUnit = ( minBase1000 = -SI_BASE_INDEX, unit = '' ) => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return value; + } const realBase10 = Math.floor(Math.log10(value)); const base10 = Math.floor(Math.max(minBase1000 * 3, realBase10)); const realBase1000 = Math.floor(realBase10 / 3); diff --git a/tgui/packages/tgui/global.d.ts b/tgui/packages/tgui/global.d.ts new file mode 100644 index 0000000000..ab8870f15c --- /dev/null +++ b/tgui/packages/tgui/global.d.ts @@ -0,0 +1,95 @@ +interface ByondType { + /** + * True if javascript is running in BYOND. + */ + IS_BYOND: boolean; + + /** + * True if browser is IE8 or lower. + */ + IS_LTE_IE8: boolean; + + /** + * True if browser is IE9 or lower. + */ + IS_LTE_IE9: boolean; + + /** + * True if browser is IE10 or lower. + */ + IS_LTE_IE10: boolean; + + /** + * True if browser is IE11 or lower. + */ + IS_LTE_IE11: boolean; + + /** + * Makes a BYOND call. + * + * If path is empty, this will trigger a Topic call. + * You can reference a specific object by setting the "src" parameter. + * + * See: https://secure.byond.com/docs/ref/skinparams.html + */ + call(path: string, params: object): void; + + /** + * Makes an asynchronous BYOND call. Returns a promise. + */ + callAsync(path: string, params: object): Promise; + + /** + * Makes a Topic call. + * + * You can reference a specific object by setting the "src" parameter. + */ + topic(params: object): void; + + /** + * Runs a command or a verb. + */ + command(command: string): void; + + /** + * Retrieves all properties of the BYOND skin element. + * + * Returns a promise with a key-value object containing all properties. + */ + winget(id: string): Promise; + + /** + * Retrieves all properties of the BYOND skin element. + * + * Returns a promise with a key-value object containing all properties. + */ + winget(id: string, propName: '*'): Promise; + + /** + * Retrieves an exactly one property of the BYOND skin element, + * as defined in `propName`. + * + * Returns a promise with the value of that property. + */ + winget(id: string, propName: string): Promise; + + /** + * Retrieves multiple properties of the BYOND skin element, + * as listen in the `propNames` array. + * + * Returns a promise with a key-value object containing listed properties. + */ + winget(id: string, propNames: string[]): Promise; + + /** + * Assigns properties to the BYOND skin element. + */ + winset(id: string, props: object): void; + + /** + * Sets a property on the BYOND skin element to a certain value. + */ + winset(id: string, propName: string, propValue: any): void; +}; + +declare const Byond: ByondType; diff --git a/tgui/packages/tgui/hotkeys.js b/tgui/packages/tgui/hotkeys.js index 0cb2083ac5..0beff6b028 100644 --- a/tgui/packages/tgui/hotkeys.js +++ b/tgui/packages/tgui/hotkeys.js @@ -4,7 +4,6 @@ * @license MIT */ -import { callByond, IS_IE8 } from './byond'; import { createLogger } from './logging'; const logger = createLogger('hotkeys'); @@ -54,6 +53,18 @@ 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_EQUAL = 187; export const KEY_MINUS = 189; @@ -70,6 +81,18 @@ const NO_PASSTHROUGH_KEYS = [ KEY_TAB, KEY_CTRL, KEY_SHIFT, + KEY_F1, + KEY_F2, + KEY_F3, + KEY_F4, + KEY_F5, + KEY_F6, + KEY_F7, + KEY_F8, + KEY_F9, + KEY_F10, + KEY_F11, + KEY_F12, ]; // Tracks the "pressed" state of keys @@ -89,6 +112,9 @@ const createHotkeyString = (ctrlKey, altKey, shiftKey, keyCode) => { if (keyCode >= 48 && keyCode <= 90) { str += String.fromCharCode(keyCode); } + else if (keyCode >= KEY_F1 && keyCode <= KEY_F12) { + str += 'F' + (keyCode - 111); + } else { str += '[' + keyCode + ']'; } @@ -135,11 +161,11 @@ const handlePassthrough = (e, eventType) => { // Send this keypress to BYOND if (eventType === 'keydown' && !keyState[keyCode]) { logger.debug('passthrough', eventType, keyData); - return callByond('', { __keydown: keyCode }); + return Byond.topic({ __keydown: keyCode }); } if (eventType === 'keyup' && keyState[keyCode]) { logger.debug('passthrough', eventType, keyData); - return callByond('', { __keyup: keyCode }); + return Byond.topic({ __keyup: keyCode }); } }; @@ -152,41 +178,45 @@ export const releaseHeldKeys = () => { if (keyState[keyCode]) { logger.log(`releasing [${keyCode}] key`); keyState[keyCode] = false; - callByond('', { __keyup: keyCode }); + Byond.topic({ __keyup: keyCode }); } } }; -const handleHotKey = (e, eventType, dispatch) => { +const hotKeySubscribers = []; + +/** + * Subscribes to a certain hotkey, and dispatches a redux action returned + * by the callback function. + */ +export const subscribeToHotKey = (keyString, fn) => { + hotKeySubscribers.push((store, keyData) => { + if (keyData.keyString === keyString) { + const action = fn(store); + if (action) { + store.dispatch(action); + } + } + }); +}; + +const handleHotKey = (e, eventType, store) => { if (eventType !== 'keyup') { return; } const keyData = getKeyData(e); const { - ctrlKey, - altKey, keyCode, hasModifierKeys, keyString, } = keyData; // Dispatch a detected hotkey as a store action - if (hasModifierKeys && !MODIFIER_KEYS.includes(keyCode)) { + if (hasModifierKeys && !MODIFIER_KEYS.includes(keyCode) + || keyCode >= KEY_F1 && keyCode <= KEY_F12) { logger.log(keyString); - // Fun stuff - if (ctrlKey && altKey && keyCode === KEY_BACKSPACE) { - // NOTE: We need to call this in a timeout, because we need a clean - // stack in order for this to be a fatal error. - setTimeout(() => { - throw new Error( - 'OOPSIE WOOPSIE!! UwU We made a fucky wucky!! A wittle' - + ' fucko boingo! The code monkeys at our headquarters are' - + ' working VEWY HAWD to fix this!'); - }); + for (let subscriberFn of hotKeySubscribers) { + subscriberFn(store, keyData); } - dispatch({ - type: 'hotKey', - payload: keyData, - }); } }; @@ -224,18 +254,17 @@ const subscribeToKeyPresses = listenerFn => { // Middleware export const hotKeyMiddleware = store => { - const { dispatch } = store; // Subscribe to key events subscribeToKeyPresses((e, eventType) => { // IE8: Can't determine the focused element, so by extension it passes // keypresses when inputs are focused. - if (!IS_IE8) { + if (!Byond.IS_LTE_IE8) { handlePassthrough(e, eventType); } - handleHotKey(e, eventType, dispatch); + handleHotKey(e, eventType, store); }); // IE8: focusin/focusout only available on IE9+ - if (!IS_IE8) { + if (!Byond.IS_LTE_IE8) { // Clean up when browser window completely loses focus subscribeToLossOfFocus(() => { releaseHeldKeys(); @@ -244,27 +273,3 @@ export const hotKeyMiddleware = store => { // Pass through store actions (do nothing) return next => action => next(action); }; - -// Reducer -export const hotKeyReducer = (state, action) => { - const { type, payload } = action; - if (type === 'hotKey') { - const { ctrlKey, altKey, keyCode } = payload; - // Toggle kitchen sink mode - if (ctrlKey && altKey && keyCode === KEY_EQUAL) { - return { - ...state, - showKitchenSink: !state.showKitchenSink, - }; - } - // Toggle layout debugger - if (ctrlKey && altKey && keyCode === KEY_MINUS) { - return { - ...state, - debugLayout: !state.debugLayout, - }; - } - return state; - } - return state; -}; diff --git a/tgui/packages/tgui/index.js b/tgui/packages/tgui/index.js index a83ec4da6e..e4ec6d285d 100644 --- a/tgui/packages/tgui/index.js +++ b/tgui/packages/tgui/index.js @@ -18,78 +18,79 @@ import './polyfills/inferno'; // Themes import './styles/main.scss'; +import './styles/themes/abductor.scss'; import './styles/themes/cardtable.scss'; +import './styles/themes/hackerman.scss'; import './styles/themes/malfunction.scss'; import './styles/themes/ntos.scss'; -import './styles/themes/hackerman.scss'; +import './styles/themes/paper.scss'; import './styles/themes/retro.scss'; import './styles/themes/syndicate.scss'; import './styles/themes/clockcult.scss'; - // import './styles/themes/paper.scss'; PAPER -// "marked": "^1.1.0", PAPER - package.json -// "dompurify": "^2.0.11" -import { loadCSS } from 'fg-loadcss'; +import { perf } from 'common/perf'; import { render } from 'inferno'; import { setupHotReloading } from 'tgui-dev-server/link/client'; -import { backendUpdate } from './backend'; -import { IS_IE8 } from './byond'; +import { backendUpdate, backendSuspendSuccess, selectBackend, sendMessage } from './backend'; import { setupDrag } from './drag'; import { logger } from './logging'; import { createStore, StoreProvider } from './store'; -const enteredBundleAt = Date.now(); +perf.mark('inception', window.__inception__); +perf.mark('init'); + const store = createStore(); let reactRoot; let initialRender = true; const renderLayout = () => { - // Mark the beginning of the render - let startedAt; - if (process.env.NODE_ENV !== 'production') { - startedAt = Date.now(); - } - try { - const state = store.getState(); - // Initial render setup - if (initialRender) { - logger.log('initial render', state); - // Setup dragging - setupDrag(state); + perf.mark('render/start'); + const state = store.getState(); + const { suspended, assets } = selectBackend(state); + // Initial render setup + if (initialRender) { + logger.log('initial render', state); + // Setup dragging + if (initialRender !== 'recycled') { + setupDrag(); } - // Start rendering - const { getRoutedComponent } = require('./routes'); - const Component = getRoutedComponent(state); - const element = ( - - - - ); - if (!reactRoot) { - reactRoot = document.getElementById('react-root'); - } - render(element, reactRoot); } - catch (err) { - logger.error('rendering error', err); - throw err; + // Start rendering + const { getRoutedComponent } = require('./routes'); + const Component = getRoutedComponent(state); + const element = ( + + + + ); + if (!reactRoot) { + reactRoot = document.getElementById('react-root'); } + render(element, reactRoot); + if (suspended) { + return; + } + perf.mark('render/finish'); // Report rendering time if (process.env.NODE_ENV !== 'production') { - const finishedAt = Date.now(); - if (initialRender) { + if (initialRender === 'recycled') { + logger.log('rendered in', + perf.measure('render/start', 'render/finish')); + } + else if (initialRender) { logger.debug('serving from:', location.href); - logger.debug('bundle entered in', timeDiff( - window.__inception__, enteredBundleAt)); - logger.debug('initialized in', timeDiff( - enteredBundleAt, startedAt)); - logger.log('rendered in', timeDiff( - startedAt, finishedAt)); - logger.log('fully loaded in', timeDiff( - window.__inception__, finishedAt)); + logger.debug('bundle entered in', + perf.measure('inception', 'init')); + logger.debug('initialized in', + perf.measure('init', 'render/start')); + logger.log('rendered in', + perf.measure('render/start', 'render/finish')); + logger.log('fully loaded in', + perf.measure('inception', 'render/finish')); } else { - logger.debug('rendered in', timeDiff(startedAt, finishedAt)); + logger.debug('rendered in', + perf.measure('render/start', 'render/finish')); } } if (initialRender) { @@ -97,12 +98,6 @@ const renderLayout = () => { } }; -const timeDiff = (startedAt, finishedAt) => { - const diff = finishedAt - startedAt; - const diffFrames = (diff / 16.6667).toFixed(2); - return `${diff}ms (${diffFrames} frames)`; -}; - // Parse JSON and report all abnormal JSON strings coming from BYOND const parseStateJson = json => { let reviver = (key, value) => { @@ -115,7 +110,7 @@ const parseStateJson = json => { }; // IE8: No reviver for you! // See: https://stackoverflow.com/questions/1288962 - if (IS_IE8) { + if (Byond.IS_LTE_IE8) { reviver = undefined; } try { @@ -136,14 +131,36 @@ const setupApp = () => { }); // Subscribe for bankend updates - window.update = stateJson => { - // NOTE: stateJson can be an object only if called manually from console. + window.update = messageJson => { + const { suspended } = selectBackend(store.getState()); + // NOTE: messageJson can be an object only if called manually from console. // This is useful for debugging tgui in external browsers, like Chrome. - const state = typeof stateJson === 'string' - ? parseStateJson(stateJson) - : stateJson; - // Backend update dispatches a store action - store.dispatch(backendUpdate(state)); + const message = typeof messageJson === 'string' + ? parseStateJson(messageJson) + : messageJson; + logger.debug(`received message '${message?.type}'`); + const { type, payload } = message; + if (type === 'update') { + if (suspended) { + logger.log('resuming'); + initialRender = 'recycled'; + } + // Backend update dispatches a store action + store.dispatch(backendUpdate(payload)); + return; + } + if (type === 'suspend') { + store.dispatch(backendSuspendSuccess()); + return; + } + if (type === 'ping') { + sendMessage({ + type: 'pingReply', + }); + return; + } + // Pass the message directly to the store + store.dispatch(message); }; // Enable hot module reloading @@ -166,9 +183,26 @@ const setupApp = () => { } window.update(stateJson); } +}; - // Dynamically load font-awesome from browser's cache - loadCSS('font-awesome.css'); +// Setup a fatal error reporter +window.__logger__ = { + fatal: (error, stack) => { + // Get last state for debugging purposes + const backendState = selectBackend(store.getState()); + const reportedState = { + config: backendState.config, + suspended: backendState.suspended, + suspending: backendState.suspending, + }; + // Send to development server + logger.log('FatalError:', error || stack); + logger.log('State:', reportedState); + // Append this data to the stack + stack += '\nState: ' + JSON.stringify(reportedState); + // Return an updated stack + return stack; + }, }; if (document.readyState === 'loading') { diff --git a/tgui/packages/tgui/layouts/Layout.js b/tgui/packages/tgui/layouts/Layout.js index 4bb0742c68..a00115e5f9 100644 --- a/tgui/packages/tgui/layouts/Layout.js +++ b/tgui/packages/tgui/layouts/Layout.js @@ -5,7 +5,7 @@ */ import { classes } from 'common/react'; -import { IS_IE8 } from '../byond'; +import { computeBoxProps, computeBoxClassName } from '../components/Box'; /** * Brings Layout__content DOM element back to focus. @@ -14,7 +14,7 @@ import { IS_IE8 } from '../byond'; */ export const refocusLayout = () => { // IE8: Focus method is seemingly fucked. - if (IS_IE8) { + if (Byond.IS_LTE_IE8) { return; } const element = document.getElementById('Layout__content'); @@ -47,6 +47,7 @@ const LayoutContent = props => { className, scrollable, children, + ...rest } = props; return (
{ 'Layout__content', scrollable && 'Layout__content--scrollable', className, - ])}> + ...computeBoxClassName(rest), + ])} + {...computeBoxProps(rest)}> {children}
); diff --git a/tgui/packages/tgui/layouts/NtosWindow.js b/tgui/packages/tgui/layouts/NtosWindow.js index 89b1a43b8c..07f17ecc54 100644 --- a/tgui/packages/tgui/layouts/NtosWindow.js +++ b/tgui/packages/tgui/layouts/NtosWindow.js @@ -4,6 +4,7 @@ * @license MIT */ +import { resolveAsset } from '../assets'; import { useBackend } from '../backend'; import { Box, Button } from '../components'; import { refocusLayout } from './Layout'; @@ -11,12 +12,16 @@ import { Window } from './Window'; export const NtosWindow = (props, context) => { const { + title, + width = 575, + height = 700, resizable, theme = 'ntos', children, } = props; const { act, data } = useBackend(context); const { + PC_device_theme, PC_batteryicon, PC_showbatteryicon, PC_batterypercent, @@ -28,6 +33,9 @@ export const NtosWindow = (props, context) => { } = data; return (
@@ -41,7 +49,8 @@ export const NtosWindow = (props, context) => { {PC_stationtime} - NtOS + {PC_device_theme === 'ntos' && 'NtOS'} + {PC_device_theme === 'syndicate' && 'Syndix'}
@@ -49,14 +58,14 @@ export const NtosWindow = (props, context) => { + src={resolveAsset(header.icon)} /> ))} {PC_ntneticon && ( + src={resolveAsset(PC_ntneticon)} /> )} {!!PC_showbatteryicon && PC_batteryicon && ( @@ -64,7 +73,7 @@ export const NtosWindow = (props, context) => { {PC_batteryicon && ( + src={resolveAsset(PC_batteryicon)} /> )} {PC_batterypercent && ( PC_batterypercent @@ -75,7 +84,7 @@ export const NtosWindow = (props, context) => { + src={resolveAsset(PC_apclinkicon)} /> )} {!!PC_showexitprogram && ( diff --git a/tgui/packages/tgui/layouts/Window.js b/tgui/packages/tgui/layouts/Window.js index 0b49cdde3e..c46a6371e6 100644 --- a/tgui/packages/tgui/layouts/Window.js +++ b/tgui/packages/tgui/layouts/Window.js @@ -7,19 +7,35 @@ import { classes } from 'common/react'; import { decodeHtmlEntities, toTitleCase } from 'common/string'; import { Component, Fragment } from 'inferno'; -import { useBackend } from '../backend'; -import { IS_IE8, runCommand, winset } from '../byond'; -import { Box, Icon } from '../components'; +import { backendSuspendStart, useBackend } from '../backend'; +import { Icon } from '../components'; import { UI_DISABLED, UI_INTERACTIVE, UI_UPDATE } from '../constants'; -import { dragStartHandler, resizeStartHandler } from '../drag'; -import { releaseHeldKeys } from '../hotkeys'; +import { toggleKitchenSink, useDebug } from '../debug'; +import { dragStartHandler, recallWindowGeometry, resizeStartHandler, setWindowKey } from '../drag'; import { createLogger } from '../logging'; +import { useDispatch } from '../store'; import { Layout, refocusLayout } from './Layout'; const logger = createLogger('Window'); +const DEFAULT_SIZE = [400, 600]; + export class Window extends Component { componentDidMount() { + const { config, suspended } = useBackend(this.context); + if (suspended) { + return; + } + logger.log('mounting'); + const options = { + size: DEFAULT_SIZE, + ...config.window, + }; + if (this.props.width && this.props.height) { + options.size = [this.props.width, this.props.height]; + } + setWindowKey(config.window.key); + recallWindowGeometry(config.window.key, options); refocusLayout(); } @@ -27,14 +43,18 @@ export class Window extends Component { const { resizable, theme, + title, children, } = this.props; const { config, - debugLayout, + suspended, } = useBackend(this.context); + const { debugLayout } = useDebug(this.context); + const dispatch = useDispatch(this.context); + const fancy = config.window?.fancy; // Determine when to show dimmer - const showDimmer = config.observer + const showDimmer = config.user.observer ? config.status < UI_DISABLED : config.status < UI_INTERACTIVE; return ( @@ -43,27 +63,25 @@ export class Window extends Component { theme={theme}> { logger.log('pressed close'); - releaseHeldKeys(); - winset(config.window, 'is-visible', false); - runCommand(`uiclose ${config.ref}`); + dispatch(backendSuspendStart()); }} />
- {children} + {!suspended && children} {showDimmer && (
)}
- {config.fancy && resizable && ( + {fancy && resizable && (
@@ -79,15 +97,26 @@ export class Window extends Component { } const WindowContent = props => { - const { scrollable, children } = props; + const { + className, + fitted, + children, + ...rest + } = props; // A bit lazy to actually write styles for it, // so we simply include a Box with margins. return ( - - {children} - + className={classes([ + 'Window__content', + className, + ])} + {...rest}> + {fitted && children || ( +
+ {children} +
+ )}
); }; @@ -106,7 +135,7 @@ const statusToColor = status => { } }; -const TitleBar = props => { +const TitleBar = (props, context) => { const { className, title, @@ -115,6 +144,7 @@ const TitleBar = props => { onDragStart, onClose, } = props; + const dispatch = useDispatch(context); return (
{ color={statusToColor(status)} name="eye" />
- {title === title.toLowerCase() - ? toTitleCase(title) - : title} + {typeof title === 'string' + && title === title.toLowerCase() + && toTitleCase(title) + || title}
fancy && onDragStart(e)} /> + {process.env.NODE_ENV !== 'production' && ( +
dispatch(toggleKitchenSink())}> + +
+ )} {!!fancy && (
{ // IE8: Use a plain character instead of a unicode symbol. // eslint-disable-next-line react/no-unknown-property onclick={onClose}> - {IS_IE8 ? 'x' : '×'} + {Byond.IS_LTE_IE8 ? 'x' : '×'}
)}
diff --git a/tgui/packages/tgui/logging.js b/tgui/packages/tgui/logging.js index 9364d4d967..95921b881d 100644 --- a/tgui/packages/tgui/logging.js +++ b/tgui/packages/tgui/logging.js @@ -5,7 +5,6 @@ */ import { sendLogEntry } from 'tgui-dev-server/link/client'; -import { callByond } from './byond'; const LEVEL_DEBUG = 0; const LEVEL_LOG = 1; @@ -33,10 +32,11 @@ const log = (level, ns, ...args) => { .filter(value => value) .join(' ') + '\nUser Agent: ' + navigator.userAgent; - callByond('', { - src: window.__ref__, - action: 'tgui:log', - log: logEntry, + Byond.topic({ + tgui: 1, + window_id: window.__windowId__, + type: 'log', + message: logEntry, }); } }; diff --git a/tgui/packages/tgui/package.json b/tgui/packages/tgui/package.json index fd2d0a0d86..70c0e0c771 100644 --- a/tgui/packages/tgui/package.json +++ b/tgui/packages/tgui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "tgui", - "version": "3.0.0", + "version": "3.1.0", "dependencies": { "@babel/core": "^7.6.2", "@babel/plugin-transform-jscript": "^7.2.0", @@ -12,16 +12,18 @@ "core-js": "^3.2.1", "css-loader": "^3.2.0", "cssnano": "^4.1.10", + "dompurify": "^2.0.11", "extract-css-chunks-webpack-plugin": "^4.6.0", "fg-loadcss": "^2.1.0", "file-loader": "^6.0.0", "inferno": "^7.3.2", + "marked": "^1.1.0", "optimize-css-assets-webpack-plugin": "^5.0.3", "regenerator-runtime": "^0.13.3", "sass": "^1.22.12", - "sass-loader": "^8.0.0", + "sass-loader": "^9.0.2", "style-loader": "^1.0.0", - "terser-webpack-plugin": "^2.1.0", + "terser-webpack-plugin": "^3.0.6", "url-loader": "^4.1.0", "webpack": "^4.40.2", "webpack-build-notifier": "^2.0.0", diff --git a/tgui/packages/tgui/public/tgui.html b/tgui/packages/tgui/public/tgui.html index c14d98a5d7..b336e29bac 100644 --- a/tgui/packages/tgui/public/tgui.html +++ b/tgui/packages/tgui/public/tgui.html @@ -5,29 +5,59 @@ - + - - - - + - + @@ -161,7 +259,7 @@ A fatal exception has occurred at 002B:C562F1B7 in TGUI. The current application will be terminated. Please remain calm. Get to the nearest NTNet workstation and send the copy of the following stack trace to: -github.com/Citadel-Station-13/Citadel-Station-13/issues. Thank you for your cooperation. +https://github.com/Citadel-Station-13/Citadel-Station-13/issues Thank you for your cooperation.