From 26cccd84c1d681bb4bb972cdedb792c23979cab3 Mon Sep 17 00:00:00 2001 From: Letter N <24603524+LetterN@users.noreply.github.com> Date: Tue, 21 Jul 2020 21:00:18 +0800 Subject: [PATCH] actual tgui4 --- tgui/packages/tgui/assets.js | 54 ++ tgui/packages/tgui/backend.js | 221 ++++++- tgui/packages/tgui/components/Box.js | 23 +- tgui/packages/tgui/components/Button.js | 8 +- tgui/packages/tgui/components/ByondUi.js | 40 +- tgui/packages/tgui/components/Chart.js | 3 +- tgui/packages/tgui/components/Flex.js | 9 +- tgui/packages/tgui/components/Input.js | 4 + tgui/packages/tgui/components/Knob.js | 3 +- tgui/packages/tgui/components/NumberInput.js | 3 +- tgui/packages/tgui/components/Section.js | 15 +- tgui/packages/tgui/components/Slider.js | 3 +- tgui/packages/tgui/components/index.js | 3 +- tgui/packages/tgui/debug/KitchenSink.js | 608 ++++++++++++++++++ tgui/packages/tgui/debug/index.js | 49 ++ tgui/packages/tgui/drag.js | 195 ++++-- tgui/packages/tgui/format.js | 3 + tgui/packages/tgui/global.d.ts | 95 +++ tgui/packages/tgui/hotkeys.js | 105 +-- tgui/packages/tgui/index.js | 158 +++-- tgui/packages/tgui/layouts/Layout.js | 9 +- tgui/packages/tgui/layouts/NtosWindow.js | 19 +- tgui/packages/tgui/layouts/Window.js | 86 ++- tgui/packages/tgui/logging.js | 10 +- tgui/packages/tgui/package.json | 8 +- tgui/packages/tgui/public/tgui.html | 152 ++++- tgui/packages/tgui/routes.js | 21 +- tgui/packages/tgui/store.js | 36 +- tgui/packages/tgui/styles/atomic/outline.scss | 10 +- tgui/packages/tgui/styles/base.scss | 13 +- .../tgui/styles/components/BlockQuote.scss | 6 +- .../tgui/styles/components/Button.scss | 16 +- .../tgui/styles/components/ColorBox.scss | 6 +- .../tgui/styles/components/Divider.scss | 6 +- .../tgui/styles/components/Dropdown.scss | 32 +- .../packages/tgui/styles/components/Flex.scss | 14 +- .../tgui/styles/components/Input.scss | 20 +- .../packages/tgui/styles/components/Knob.scss | 38 -- .../tgui/styles/components/LabeledList.scss | 12 +- .../tgui/styles/components/NoticeBox.scss | 11 +- .../tgui/styles/components/NumberInput.scss | 31 +- .../tgui/styles/components/ProgressBar.scss | 14 +- .../tgui/styles/components/Section.scss | 35 +- .../tgui/styles/components/Slider.scss | 21 +- .../tgui/styles/components/Table.scss | 4 +- .../packages/tgui/styles/components/Tabs.scss | 17 +- .../tgui/styles/components/TextArea.scss | 158 ++--- .../tgui/styles/components/Tooltip.scss | 30 +- .../tgui/styles/interfaces/CameraConsole.scss | 27 +- .../tgui/styles/interfaces/NuclearBomb.scss | 13 +- .../tgui/styles/interfaces/Roulette.scss | 1 + tgui/packages/tgui/styles/layouts/Layout.scss | 22 +- .../tgui/styles/layouts/NtosHeader.scss | 8 +- .../tgui/styles/layouts/NtosWindow.scss | 12 +- .../tgui/styles/layouts/TitleBar.scss | 35 +- tgui/packages/tgui/styles/layouts/Window.scss | 36 +- tgui/packages/tgui/styles/main.scss | 4 +- tgui/packages/tgui/styles/reset.scss | 9 +- .../packages/tgui/styles/themes/abductor.scss | 59 ++ .../tgui/styles/themes/cardtable.scss | 8 +- .../tgui/styles/themes/hackerman.scss | 5 +- .../tgui/styles/themes/malfunction.scss | 1 - tgui/packages/tgui/styles/themes/ntos.scss | 1 - tgui/packages/tgui/styles/themes/paper.scss | 199 ++++-- tgui/packages/tgui/styles/themes/retro.scss | 8 +- .../tgui/styles/themes/syndicate.scss | 1 - 66 files changed, 2192 insertions(+), 694 deletions(-) create mode 100644 tgui/packages/tgui/assets.js create mode 100644 tgui/packages/tgui/debug/KitchenSink.js create mode 100644 tgui/packages/tgui/debug/index.js create mode 100644 tgui/packages/tgui/global.d.ts create mode 100644 tgui/packages/tgui/styles/themes/abductor.scss diff --git a/tgui/packages/tgui/assets.js b/tgui/packages/tgui/assets.js new file mode 100644 index 0000000000..b0f71bb9ff --- /dev/null +++ b/tgui/packages/tgui/assets.js @@ -0,0 +1,54 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { loadCSS as fgLoadCSS } from 'fg-loadcss'; +import { createLogger } from './logging'; + +const logger = createLogger('assets'); + +const EXCLUDED_PATTERNS = [ + /v4shim/i, +]; + +const loadedStyles = []; +const loadedMappings = {}; + +export const loadCSS = url => { + if (loadedStyles.includes(url)) { + return; + } + loadedStyles.push(url); + logger.log(`loading stylesheet '${url}'`); + fgLoadCSS(url); +}; + +export const resolveAsset = name => ( + loadedMappings[name] || name +); + +export const assetMiddleware = store => next => action => { + const { type, payload } = action; + if (type === 'asset/stylesheet') { + loadCSS(payload); + return; + } + if (type === 'asset/mappings') { + for (let name of Object.keys(payload)) { + // Skip anything that matches excluded patterns + if (EXCLUDED_PATTERNS.some(regex => regex.test(name))) { + continue; + } + const url = payload[name]; + const ext = name.split('.').pop(); + loadedMappings[name] = url; + if (ext === 'css') { + loadCSS(url); + } + } + return; + } + next(action); +}; diff --git a/tgui/packages/tgui/backend.js b/tgui/packages/tgui/backend.js index 090970fe50..dab86ee918 100644 --- a/tgui/packages/tgui/backend.js +++ b/tgui/packages/tgui/backend.js @@ -11,8 +11,12 @@ * @license MIT */ +import { perf } from 'common/perf'; import { UI_DISABLED, UI_INTERACTIVE } from './constants'; -import { callByond } from './byond'; +import { releaseHeldKeys } from './hotkeys'; +import { createLogger } from './logging'; + +const logger = createLogger('backend'); export const backendUpdate = state => ({ type: 'backend/update', @@ -24,7 +28,23 @@ export const backendSetSharedState = (key, nextState) => ({ payload: { key, nextState }, }); -export const backendReducer = (state, action) => { +export const backendSuspendStart = () => ({ + type: 'backend/suspendStart', +}); + +export const backendSuspendSuccess = () => ({ + type: 'backend/suspendSuccess', + payload: { + timestamp: Date.now(), + }, +}); + +const initialState = { + config: {}, + data: {}, +}; + +export const backendReducer = (state = initialState, action) => { const { type, payload } = action; if (type === 'backend/update') { @@ -63,6 +83,7 @@ export const backendReducer = (state, action) => { shared, visible, interactive, + suspended: false, }; } @@ -77,30 +98,175 @@ export const backendReducer = (state, action) => { }; } + if (type === 'backend/suspendStart') { + return { + ...state, + suspending: true, + }; + } + + if (type === 'backend/suspendSuccess') { + const { timestamp } = payload; + return { + ...state, + data: {}, + shared: {}, + config: { + ...state.config, + title: '', + status: 1, + }, + suspending: false, + suspended: timestamp, + }; + } + return state; }; +export const backendMiddleware = store => { + let fancyState; + let suspendInterval; + + return next => action => { + const { config, suspended } = selectBackend(store.getState()); + const { type, payload } = action; + + if (type === 'backend/suspendStart' && !suspendInterval) { + logger.log(`suspending (${window.__windowId__})`); + // Keep sending suspend messages until it succeeds. + // It may fail multiple times due to topic rate limiting. + const suspendFn = () => sendMessage({ + type: 'suspend', + }); + suspendFn(); + suspendInterval = setInterval(suspendFn, 2000); + } + + if (type === 'backend/suspendSuccess') { + clearInterval(suspendInterval); + suspendInterval = undefined; + releaseHeldKeys(); + Byond.winset(window.__windowId__, { + 'is-visible': false, + }); + } + + if (type === 'backend/update') { + const fancy = payload.config?.window?.fancy; + // Initialize fancy state + if (fancyState === undefined) { + fancyState = fancy; + } + // React to changes in fancy + else if (fancyState !== fancy) { + logger.log('changing fancy mode to', fancy); + fancyState = fancy; + Byond.winset(window.__windowId__, { + titlebar: !fancy, + 'can-resize': !fancy, + }); + } + } + + if (type === 'backend/update' && suspended) { + // We schedule this for the next tick here because resizing and unhiding + // during the same tick will flash with a white background. + setImmediate(() => { + perf.mark('resume/start'); + // Doublecheck if we are not re-suspended. + const { suspended } = selectBackend(store.getState()); + if (suspended) { + return; + } + Byond.winset(window.__windowId__, { + 'is-visible': true, + }); + perf.mark('resume/finish'); + if (process.env.NODE_ENV !== 'production') { + logger.log('visible in', + perf.measure('render/finish', 'resume/finish')); + } + }); + } + + return next(action); + }; +}; + +/** + * Sends a message to /datum/tgui_window. + */ +export const sendMessage = (message = {}) => { + const { payload, ...rest } = message; + const data = { + // Message identifying header + tgui: 1, + window_id: window.__windowId__, + // Message body + ...rest, + }; + // JSON-encode the payload + if (payload !== null && payload !== undefined) { + data.payload = JSON.stringify(payload); + } + Byond.topic(data); +}; + +/** + * Sends an action to `ui_act` on `src_object` that this tgui window + * is associated with. + */ +export const sendAct = (action, payload = {}) => { + // Validate that payload is an object + const isObject = typeof payload === 'object' + && payload !== null + && !Array.isArray(payload); + if (!isObject) { + logger.error(`Payload for act() must be an object, got this:`, payload); + return; + } + sendMessage({ + type: 'act/' + action, + payload, + }); +}; + /** * @typedef BackendState * @type {{ * config: { * title: string, * status: number, - * screen: string, - * style: string, * interface: string, - * fancy: number, - * locked: number, - * observer: number, - * window: string, - * ref: string, + * user: { + * name: string, + * ckey: string, + * observer: number, + * }, + * window: { + * key: string, + * size: [number, number], + * fancy: boolean, + * locked: boolean, + * }, * }, * data: any, + * shared: any, * visible: boolean, * interactive: boolean, + * suspending: boolean, + * suspended: boolean, * }} */ +/** + * Selects a backend-related slice of Redux state + * + * @return {BackendState} + */ +export const selectBackend = state => state.backend || {}; + /** * A React hook (sort of) for getting tgui state and related functions. * @@ -108,21 +274,16 @@ export const backendReducer = (state, action) => { * be used in functional components. * * @return {BackendState & { - * act: (action: string, params?: object) => void, + * act: sendAct, * }} */ export const useBackend = context => { const { store } = context; - const state = store.getState(); - const ref = state.config.ref; - const act = (action, params = {}) => { - callByond('', { - src: ref, - action, - ...params, - }); + const state = selectBackend(store.getState()); + return { + ...state, + act: sendAct, }; - return { ...state, act }; }; /** @@ -140,7 +301,7 @@ export const useBackend = context => { */ export const useLocalState = (context, key, initialState) => { const { store } = context; - const state = store.getState(); + const state = selectBackend(store.getState()); const sharedStates = state.shared ?? {}; const sharedState = (key in sharedStates) ? sharedStates[key] @@ -148,7 +309,11 @@ export const useLocalState = (context, key, initialState) => { return [ sharedState, nextState => { - store.dispatch(backendSetSharedState(key, nextState)); + store.dispatch(backendSetSharedState(key, ( + typeof nextState === 'function' + ? nextState(sharedState) + : nextState + ))); }, ]; }; @@ -169,8 +334,7 @@ export const useLocalState = (context, key, initialState) => { */ export const useSharedState = (context, key, initialState) => { const { store } = context; - const state = store.getState(); - const ref = state.config.ref; + const state = selectBackend(store.getState()); const sharedStates = state.shared ?? {}; const sharedState = (key in sharedStates) ? sharedStates[key] @@ -178,11 +342,14 @@ export const useSharedState = (context, key, initialState) => { return [ sharedState, nextState => { - callByond('', { - src: ref, - action: 'tgui:setSharedState', + sendMessage({ + type: 'setSharedState', key, - value: JSON.stringify(nextState) || '', + value: JSON.stringify( + typeof nextState === 'function' + ? nextState(sharedState) + : nextState + ) || '', }); }, ]; diff --git a/tgui/packages/tgui/components/Box.js b/tgui/packages/tgui/components/Box.js index cb86890a97..a85c692e9a 100644 --- a/tgui/packages/tgui/components/Box.js +++ b/tgui/packages/tgui/components/Box.js @@ -9,17 +9,22 @@ import { createVNode } from 'inferno'; import { ChildFlags, VNodeFlags } from 'inferno-vnode-flags'; import { CSS_COLORS } from '../constants'; -const UNIT_PX = 12; - /** * Coverts our rem-like spacing unit into a CSS unit. */ export const unit = value => { if (typeof value === 'string') { + // Transparently convert pixels into rem units + if (value.endsWith('px') && !Byond.IS_LTE_IE8) { + return parseFloat(value) / 12 + 'rem'; + } return value; } if (typeof value === 'number') { - return (value * UNIT_PX) + 'px'; + if (Byond.IS_LTE_IE8) { + return value * 12 + 'px'; + } + return value + 'rem'; } }; @@ -28,10 +33,10 @@ export const unit = value => { */ export const halfUnit = value => { if (typeof value === 'string') { - return value; + return unit(value); } if (typeof value === 'number') { - return (value * UNIT_PX * 0.5) + 'px'; + return unit(value * 0.5); } }; @@ -90,7 +95,13 @@ const styleMapperByPropName = { maxHeight: mapUnitPropTo('max-height', unit), fontSize: mapUnitPropTo('font-size', unit), fontFamily: mapRawPropTo('font-family'), - lineHeight: mapRawPropTo('line-height'), + lineHeight: (style, value) => { + if (!isFalsy(value)) { + style['line-height'] = typeof value === 'number' + ? value + : unit(value); + } + }, opacity: mapRawPropTo('opacity'), textAlign: mapRawPropTo('text-align'), verticalAlign: mapRawPropTo('vertical-align'), diff --git a/tgui/packages/tgui/components/Button.js b/tgui/packages/tgui/components/Button.js index 52adf286ad..bb8b4bcbf0 100644 --- a/tgui/packages/tgui/components/Button.js +++ b/tgui/packages/tgui/components/Button.js @@ -6,7 +6,6 @@ import { classes, pureComponentHooks } from 'common/react'; import { Component, createRef } from 'inferno'; -import { IS_IE8 } from '../byond'; import { KEY_ENTER, KEY_ESCAPE, KEY_SPACE } from '../hotkeys'; import { refocusLayout } from '../layouts'; import { createLogger } from '../logging'; @@ -61,7 +60,7 @@ export const Button = props => { className, ])} tabIndex={!disabled && '0'} - unselectable={IS_IE8} + unselectable={Byond.IS_LTE_IE8} onclick={e => { refocusLayout(); if (!disabled && onClick) { @@ -87,7 +86,10 @@ export const Button = props => { }} {...rest}> {icon && ( - + )} {content} {children} diff --git a/tgui/packages/tgui/components/ByondUi.js b/tgui/packages/tgui/components/ByondUi.js index db9c5822ba..2369cc7993 100644 --- a/tgui/packages/tgui/components/ByondUi.js +++ b/tgui/packages/tgui/components/ByondUi.js @@ -7,7 +7,6 @@ import { shallowDiffers } from 'common/react'; import { debounce } from 'common/timer'; import { Component, createRef } from 'inferno'; -import { callByond, IS_IE8 } from '../byond'; import { createLogger } from '../logging'; import { computeBoxProps } from './Box'; @@ -28,16 +27,12 @@ const createByondUiElement = elementId => { render: params => { logger.log(`rendering '${id}'`); byondUiStack[index] = id; - callByond('winset', { - ...params, - id, - }); + Byond.winset(id, params); }, unmount: () => { logger.log(`unmounting '${id}'`); byondUiStack[index] = null; - callByond('winset', { - id, + Byond.winset(id, { parent: '', }); }, @@ -51,8 +46,7 @@ window.addEventListener('beforeunload', () => { if (typeof id === 'string') { logger.log(`unmounting '${id}' (beforeunload)`); byondUiStack[index] = null; - callByond('winset', { - id, + Byond.winset(id, { parent: '', }); } @@ -83,7 +77,7 @@ export class ByondUi extends Component { this.byondUiElement = createByondUiElement(props.params?.id); this.handleResize = debounce(() => { this.forceUpdate(); - }, 500); + }, 100); } shouldComponentUpdate(nextProps) { @@ -101,16 +95,17 @@ export class ByondUi extends Component { componentDidMount() { // IE8: It probably works, but fuck you anyway. - if (IS_IE8) { + if (Byond.IS_LTE_IE10) { return; } window.addEventListener('resize', this.handleResize); - return this.componentDidUpdate(); + this.componentDidUpdate(); + this.handleResize(); } componentDidUpdate() { // IE8: It probably works, but fuck you anyway. - if (IS_IE8) { + if (Byond.IS_LTE_IE10) { return; } const { @@ -119,6 +114,7 @@ export class ByondUi extends Component { const box = getBoundingBox(this.containerRef.current); logger.log('bounding box', box); this.byondUiElement.render({ + parent: window.__windowId__, ...params, pos: box.pos[0] + ',' + box.pos[1], size: box.size[0] + 'x' + box.size[1], @@ -127,7 +123,7 @@ export class ByondUi extends Component { componentWillUnmount() { // IE8: It probably works, but fuck you anyway. - if (IS_IE8) { + if (Byond.IS_LTE_IE10) { return; } window.removeEventListener('resize', this.handleResize); @@ -135,26 +131,16 @@ export class ByondUi extends Component { } render() { - const { - parent, - params, - ...rest - } = this.props; + const { params, ...rest } = this.props; const type = params?.type; const boxProps = computeBoxProps(rest); return (
- {type === 'button' && } + {/* Filler */} +
); } } - -const ButtonMock = () => ( -
-); diff --git a/tgui/packages/tgui/components/Chart.js b/tgui/packages/tgui/components/Chart.js index 16ed82c355..77913779db 100644 --- a/tgui/packages/tgui/components/Chart.js +++ b/tgui/packages/tgui/components/Chart.js @@ -7,7 +7,6 @@ import { map, zipWith } from 'common/collections'; import { pureComponentHooks } from 'common/react'; import { Component, createRef } from 'inferno'; -import { IS_IE8 } from '../byond'; import { Box } from './Box'; const normalizeData = (data, scale, rangeX, rangeY) => { @@ -123,5 +122,5 @@ const Stub = props => null; // IE8: No inline svg support export const Chart = { - Line: IS_IE8 ? Stub : LineChart, + Line: Byond.IS_LTE_IE8 ? Stub : LineChart, }; diff --git a/tgui/packages/tgui/components/Flex.js b/tgui/packages/tgui/components/Flex.js index 4ee69a1902..02d2fac314 100644 --- a/tgui/packages/tgui/components/Flex.js +++ b/tgui/packages/tgui/components/Flex.js @@ -5,7 +5,6 @@ */ import { classes, pureComponentHooks } from 'common/react'; -import { IS_IE8 } from '../byond'; import { Box, unit } from './Box'; export const computeFlexProps = props => { @@ -22,10 +21,10 @@ export const computeFlexProps = props => { return { className: classes([ 'Flex', - IS_IE8 && ( + Byond.IS_LTE_IE10 && ( direction === 'column' - ? 'Flex--ie8--column' - : 'Flex--ie8' + ? 'Flex--iefix--column' + : 'Flex--iefix' ), inline && 'Flex--inline', spacing > 0 && 'Flex--spacing--' + spacing, @@ -63,7 +62,7 @@ export const computeFlexItemProps = props => { return { className: classes([ 'Flex__item', - IS_IE8 && 'Flex__item--ie8', + Byond.IS_LTE_IE10 && 'Flex__item--iefix', className, ]), style: { diff --git a/tgui/packages/tgui/components/Input.js b/tgui/packages/tgui/components/Input.js index 25b85893ed..623fa92bb4 100644 --- a/tgui/packages/tgui/components/Input.js +++ b/tgui/packages/tgui/components/Input.js @@ -83,6 +83,10 @@ export class Input extends Component { if (input) { input.value = toInputValue(nextValue); } + + if (this.props.autoFocus) { + setTimeout(() => input.focus(), 1); + } } componentDidUpdate(prevProps, prevState) { diff --git a/tgui/packages/tgui/components/Knob.js b/tgui/packages/tgui/components/Knob.js index e72b15f195..4861e71bf3 100644 --- a/tgui/packages/tgui/components/Knob.js +++ b/tgui/packages/tgui/components/Knob.js @@ -6,7 +6,6 @@ import { keyOfMatchingRange, scale } from 'common/math'; import { classes } from 'common/react'; -import { IS_IE8 } from '../byond'; import { computeBoxClassName, computeBoxProps } from './Box'; import { DraggableControl } from './DraggableControl'; import { NumberInput } from './NumberInput'; @@ -14,7 +13,7 @@ import { NumberInput } from './NumberInput'; export const Knob = props => { // IE8: I don't want to support a yet another component on IE8. // IE8: It also can't handle SVG. - if (IS_IE8) { + if (Byond.IS_LTE_IE8) { return ( ); diff --git a/tgui/packages/tgui/components/NumberInput.js b/tgui/packages/tgui/components/NumberInput.js index 806c81d542..cba6f5025e 100644 --- a/tgui/packages/tgui/components/NumberInput.js +++ b/tgui/packages/tgui/components/NumberInput.js @@ -7,7 +7,6 @@ import { clamp } from 'common/math'; import { classes, pureComponentHooks } from 'common/react'; import { Component, createRef } from 'inferno'; -import { IS_IE8 } from '../byond'; import { AnimatedNumber } from './AnimatedNumber'; import { Box } from './Box'; @@ -168,7 +167,7 @@ export class NumberInput extends Component { const renderContentElement = value => (
+ unselectable={Byond.IS_LTE_IE8}> {value + (unit ? ' ' + unit : '')}
); diff --git a/tgui/packages/tgui/components/Section.js b/tgui/packages/tgui/components/Section.js index 2f9fa239b1..af59bc4022 100644 --- a/tgui/packages/tgui/components/Section.js +++ b/tgui/packages/tgui/components/Section.js @@ -5,7 +5,7 @@ */ import { classes, isFalsy, pureComponentHooks } from 'common/react'; -import { Box } from './Box'; +import { computeBoxClassName, computeBoxProps } from './Box'; export const Section = props => { const { @@ -13,20 +13,22 @@ export const Section = props => { title, level = 1, buttons, - content, + fill, children, ...rest } = props; const hasTitle = !isFalsy(title) || !isFalsy(buttons); - const hasContent = !isFalsy(content) || !isFalsy(children); + const hasContent = !isFalsy(children); return ( - + {...computeBoxProps(rest)}> {hasTitle && (
@@ -39,11 +41,10 @@ export const Section = props => { )} {hasContent && (
- {content} {children}
)} - +
); }; diff --git a/tgui/packages/tgui/components/Slider.js b/tgui/packages/tgui/components/Slider.js index 7ae74684fb..005e6c1f8f 100644 --- a/tgui/packages/tgui/components/Slider.js +++ b/tgui/packages/tgui/components/Slider.js @@ -6,14 +6,13 @@ import { clamp01, keyOfMatchingRange, scale } from 'common/math'; import { classes } from 'common/react'; -import { IS_IE8 } from '../byond'; import { computeBoxClassName, computeBoxProps } from './Box'; import { DraggableControl } from './DraggableControl'; import { NumberInput } from './NumberInput'; export const Slider = props => { // IE8: I don't want to support a yet another component on IE8. - if (IS_IE8) { + if (Byond.IS_LTE_IE8) { return ( ); diff --git a/tgui/packages/tgui/components/index.js b/tgui/packages/tgui/components/index.js index e7631116f7..5580cc38fb 100644 --- a/tgui/packages/tgui/components/index.js +++ b/tgui/packages/tgui/components/index.js @@ -14,6 +14,7 @@ export { Collapsible } from './Collapsible'; export { ColorBox } from './ColorBox'; export { Dimmer } from './Dimmer'; export { Divider } from './Divider'; +export { DraggableControl } from './DraggableControl'; export { Dropdown } from './Dropdown'; export { Flex } from './Flex'; export { Grid } from './Grid'; @@ -29,7 +30,7 @@ export { ProgressBar } from './ProgressBar'; export { Section } from './Section'; export { Slider } from './Slider'; export { Table } from './Table'; +export { TextArea } from './TextArea'; export { Tabs } from './Tabs'; export { Tooltip } from './Tooltip'; export { TimeDisplay } from './TimeDisplay'; -// export { TextArea } from './TextArea'; PAPER diff --git a/tgui/packages/tgui/debug/KitchenSink.js b/tgui/packages/tgui/debug/KitchenSink.js new file mode 100644 index 0000000000..050adcf193 --- /dev/null +++ b/tgui/packages/tgui/debug/KitchenSink.js @@ -0,0 +1,608 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { Fragment } from 'inferno'; +import { useBackend, useLocalState } from '../backend'; +import { BlockQuote, Box, Button, ByondUi, Collapsible, DraggableControl, Flex, Icon, Input, Knob, LabeledList, NoticeBox, NumberInput, ProgressBar, Section, Slider, Tabs, Tooltip } from '../components'; +import { formatSiUnit } from '../format'; +import { Window } from '../layouts'; +import { createLogger } from '../logging'; + +const logger = createLogger('KitchenSink'); + +const COLORS_SPECTRUM = [ + 'red', + 'orange', + 'yellow', + 'olive', + 'green', + 'teal', + 'blue', + 'violet', + 'purple', + 'pink', + 'brown', + 'grey', +]; + +const COLORS_STATES = [ + 'good', + 'average', + 'bad', + 'black', + 'white', +]; + +const PAGES = [ + { + title: 'Button', + component: () => KitchenSinkButton, + }, + { + title: 'Box', + component: () => KitchenSinkBox, + }, + { + title: 'Flex & Sections', + component: () => KitchenSinkFlexAndSections, + }, + { + title: 'ProgressBar', + component: () => KitchenSinkProgressBar, + }, + { + title: 'Tabs', + component: () => KitchenSinkTabs, + }, + { + title: 'Tooltip', + component: () => KitchenSinkTooltip, + }, + { + title: 'Input / Control', + component: () => KitchenSinkInput, + }, + { + title: 'Collapsible', + component: () => KitchenSinkCollapsible, + }, + { + title: 'BlockQuote', + component: () => KitchenSinkBlockQuote, + }, + { + title: 'ByondUi', + component: () => KitchenSinkByondUi, + }, + { + title: 'Themes', + component: () => KitchenSinkThemes, + }, + { + title: 'Storage', + component: () => KitchenSinkStorage, + }, +]; + +export const KitchenSink = (props, context) => { + const [theme] = useLocalState(context, 'kitchenSinkTheme'); + const [pageIndex, setPageIndex] = useLocalState(context, 'pageIndex', 0); + const PageComponent = PAGES[pageIndex].component(); + return ( + + + +
+ {PAGES.map((page, i) => ( + + ))} +
+
+ + + + + +
+
+ ); +}; + +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.