Merge pull request #4304 from CHOMPStation2/upstream-merge-13013

[MIRROR] TGUI API improvements port
This commit is contained in:
Nadyr
2022-05-31 03:36:52 -04:00
committed by GitHub
36 changed files with 670 additions and 272 deletions
+4
View File
@@ -23,6 +23,10 @@ SUBSYSTEM_DEF(tgui)
/datum/controller/subsystem/tgui/PreInit()
basehtml = file2text('tgui/public/tgui.html')
// Inject inline polyfills
var/polyfill = file2text('tgui/public/tgui-polyfill.min.js')
polyfill = "<script>\n[polyfill]\n</script>"
basehtml = replacetextEx(basehtml, "<!-- tgui:inline-polyfill -->", polyfill)
/datum/controller/subsystem/tgui/Shutdown()
close_all_uis()
+1 -1
View File
@@ -92,7 +92,7 @@
if(!window.is_ready())
window.initialize(
fancy = user.client.prefs.tgui_fancy,
inline_assets = list(
assets = list(
get_asset_datum(/datum/asset/simple/tgui_common),
get_asset_datum(/datum/asset/simple/tgui)
))
+45 -12
View File
@@ -18,8 +18,11 @@
var/message_queue
var/sent_assets = list()
// Vars passed to initialize proc (and saved for later)
var/inline_assets
var/fancy
var/initial_fancy
var/initial_assets
var/initial_inline_html
var/initial_inline_js
var/initial_inline_css
/**
* public
@@ -45,21 +48,26 @@
* state. You can begin sending messages right after initializing. Messages
* will be put into the queue until the window finishes loading.
*
* optional inline_assets list List of assets to inline into the html.
* optional assets list List of assets to inline into the html.
* optional inline_html string Custom HTML to inject.
* optional fancy bool If TRUE, will hide the window titlebar.
*/
/datum/tgui_window/proc/initialize(
inline_assets = list(),
fancy = FALSE,
assets = list(),
inline_html = "",
fancy = FALSE)
inline_js = "",
inline_css = "")
#ifdef TGUI_DEBUGGING
log_tgui(client, "[id]/initiailize ([src])")
#endif
if(!client)
return
src.inline_assets = inline_assets
src.fancy = fancy
src.initial_fancy = fancy
src.initial_assets = assets
src.initial_inline_html = inline_html
src.initial_inline_js = inline_js
src.initial_inline_css = inline_css
status = TGUI_WINDOW_LOADING
fatally_errored = FALSE
// Build window options
@@ -72,9 +80,9 @@
// Generate page html
var/html = SStgui.basehtml
html = replacetextEx(html, "\[tgui:windowId]", id)
// Inject inline assets
// Inject assets
var/inline_assets_str = ""
for(var/datum/asset/asset in inline_assets)
for(var/datum/asset/asset in assets)
var/mappings = asset.get_url_mappings()
for(var/name in mappings)
var/url = mappings[name]
@@ -87,8 +95,17 @@
if(length(inline_assets_str))
inline_assets_str = "<script>\n" + inline_assets_str + "</script>\n"
html = replacetextEx(html, "<!-- tgui:assets -->\n", inline_assets_str)
// Inject custom HTML
html = replacetextEx(html, "<!-- tgui:html -->\n", inline_html)
// Inject inline HTML
if (inline_html)
html = replacetextEx(html, "<!-- tgui:inline-html -->", inline_html)
// Inject inline JS
if (inline_js)
inline_js = "<script>\n[inline_js]\n</script>"
html = replacetextEx(html, "<!-- tgui:inline-js -->", inline_js)
// Inject inline CSS
if (inline_css)
inline_css = "<style>\n[inline_css]\n</style>"
html = replacetextEx(html, "<!-- tgui:inline-css -->", inline_css)
// Open the window
client << browse(html, "window=[id];[options]")
// Detect whether the control is a browser
@@ -281,6 +298,17 @@
: "[id].browser:update")
message_queue = null
/**
* public
*
* Replaces the inline HTML content.
*
* required inline_html string HTML to inject
*/
/datum/tgui_window/proc/replace_html(inline_html = "")
client << output(url_encode(inline_html), is_browser \
? "[id]:replaceHtml" \
: "[id].browser:replaceHtml")
/**
* private
@@ -325,7 +353,12 @@
client << link(href_list["url"])
if("cacheReloaded")
// Reinitialize
initialize(inline_assets = inline_assets, fancy = fancy)
initialize(
fancy = initial_fancy,
assets = initial_assets,
inline_html = initial_inline_html,
inline_js = initial_inline_js,
inline_css = initial_inline_css)
// Resend the assets
for(var/asset in sent_assets)
send_asset(asset)
+7 -2
View File
@@ -217,8 +217,13 @@
$wrap.width($wrap.width() + 2); //Dumb hack to fix a bizarre sizing bug
var docWidth = $wrap.outerWidth(),
docHeight = $wrap.outerHeight();
var pixelRatio = 1;
if (window.devicePixelRatio) {
pixelRatio = window.devicePixelRatio;
}
var docWidth = Math.floor($wrap.outerWidth() * pixelRatio),
docHeight = Math.floor($wrap.outerHeight() * pixelRatio);
if (posY + docHeight > map.size.y) { //Is the bottom edge below the window? Snap it up if so
posY = (posY - docHeight) - realIconSize - tooltip.padding;
+1
View File
@@ -376,6 +376,7 @@ rules:
ignoreUrls: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
}]
## Enforce a maximum number of lines per file
# max-lines: error
+8 -4
View File
@@ -33,6 +33,13 @@ If you were already familiar with an older, Ractive-based tgui, and want
to translate concepts between old and new tgui, read this
[interface conversion guide](docs/converting-old-tgui-interfaces.md).
### Other Documentation
- [Component Reference](docs/component-reference.md) - UI building blocks
- [Using TGUI and Byond API for custom HTML popups](docs/tgui-for-custom-html-popups.md)
- [Chat Embedded Components](docs/chat-embedded-components.md)
- [Writing Tests](docs/writing-tests.md)
## Pre-requisites
You will need these programs to start developing in tgui:
@@ -40,6 +47,7 @@ You will need these programs to start developing in tgui:
- [Node v16.13+](https://nodejs.org/en/download/)
- **LTS** release is recommended instead of latest
- [Yarn v1.22.4+](https://yarnpkg.com/getting-started/install) (optional)
- You can run `npm install -g yarn` to install it.
- [Git Bash](https://git-scm.com/downloads)
or [MSys2](https://www.msys2.org/) (optional)
@@ -190,10 +198,6 @@ Add stylesheets here if you really need a fine control over your UI styles.
- `/packages/tgui/styles/themes` - Contains all the various themes you can
use in tgui. Each theme must be registered in `webpack.config.js` file.
## Component Reference
See: [Component Reference](docs/component-reference.md).
## License
Source code is covered by /tg/station's parent license - **AGPL-3.0**
+259
View File
@@ -0,0 +1,259 @@
# Using TGUI and Byond API for custom HTML popups
TGUI in its current form would not exist without a very robust underlying layer that interfaces TGUI code with the BYOND browser component. This very layer can also be used to write simple and robust HTML popups, with access to many convenient APIs. In this article, you'll learn how to make a TGUI powered HTML popup and leverage all APIs that it provides.
## How to create a window
TGUI in order to create a window (popup) uses the `/datum/tgui_window` class. Feel free to take a look at its [source code](../../code/modules/tgui/tgui_window.dm), as all of its procs are very well documented. This class takes care of spawning the BYOND's browser element, normalizes the browser environment (because users might have IE8 on their system, or in future, it might be Microsoft Edge) and specifies a very rigid communication protocol between DM and JS.
> **Notice:** Because `/datum/tgui_window` includes a lot of boilerplate in the final html that it displays in the browser, it is somewhat more expensive to render than a traditional, dumb popup using a `browse()` proc call. Therefore, its best to use it with static popups or very custom pieces of client-side code, e.g. stat panel, chat or a background music player.
Create a window that prints hello world.
```dm
var/datum/tgui_window/window = new(usr.client, "custom_popup")
window.initialize(
inline_html = "<h1>Hello world!</h1>",
)
```
Here, `custom_popup` is a unique id for the BYOND skin element that this window uses, and it can be anything you want. If you want to reference a specific element from `interface/skin.dmf`, you can use that id instead, and UI will initialize inside of that element. This is how for example chat initializes itself, by using a `browseroutput` id, which is also specified in `interface/skin.dmf`.
In case you want to re-initialize it with different content, you can do that as well by calling `initialize` again with different arguments.
```dm
window.initialize(
inline_html = "<h2>Hello world, but smaller!</h2>",
)
```
You can close the window as easily as you've opened it.
```dm
window.close()
```
## Sending assets
TGUI in /tg/station codebase has `/datum/asset`, that packs scripts and stylesheets for delivery via CDN for efficiency. TGUI internally uses this asset system to render TGUI interfaces *proper* and TGUI chat. This is a snippet from internal TGUI code:
```dm
window.initialize(
fancy = user.client.prefs.read_preference(
/datum/preference/toggle/tgui_fancy
),
assets = list(
get_asset_datum(/datum/asset/simple/tgui),
))
```
You can see two new arguments:
- `fancy` - See [Fancy mode](#fancy-mode)
- `assets` - This is a list of asset datums, and all JS and CSS in the assets will be loaded in the page.
Using asset datums has a big benefit over including `<script>` and `<link>` in normal html popups; If your asset is not available for any reason at the moment (e.g. network is down or packet loss), tgui window will retry loading those assets multiple times.
You can also send assets dynamically on a running instance of the window, and they will be included automatically without refreshing the page.
```dm
window.send_asset(asset)
```
Finally, you can use the `Byond` API object to load JS and CSS files directly via URLs.
```html
<script>
Byond.loadJs('https://example.com/bundle.js');
Byond.loadCss('https://example.com/bundle.css');
</script>
```
## Inlined HTML, CSS and JS
You can also make a popup that doesn't rely on network requests to get JS and CSS. In the following case, the entirety of the page will be contained in a single HTML file.
```dm
window.initialize(
inline_html = "<h1>Hello world!</h1>",
inline_js = "window.alert('Warning!')",
inline_css = "h1 { color: red }",
)
```
You can also do the same by splitting your code into separate files, and then leveraging tgui window to serve it all as one big HTML file.
```dm
window.initialize(
inline_html = file2text('code/modules/thing/thing.html'),
inline_js = file2text('code/modules/thing/thing.js'),
inline_css = file2text('code/modules/thing/thing.css'),
)
```
If you need to inline multiple JS or CSS files, you can concatenate them for now, and separate contents of each file with an `\n` symbol. *This can be a point of improvement (add support for file lists)*.
## Fancy mode
You may have noticed the fancy mode in previous snippets:
```dm
window.initialize(fancy = TRUE)
```
This removes the native window titlebar and border, which effectively turns window into a floating panel. TGUI heavily uses this option to draw completely custom, fancy windows. You can use it too, but not having the default titlebar limits usability of the browser window, since you can't even close it or drag around without implementing that functionality yourself. This mode might be useful for creating popups and tooltips.
## Communication
It is very often necessary to exchange data between DM and JS, and in vanilla BYOND programming it is a huge pain in the butt, because the `browse()` API is very convoluted, out of box it can send only strings, and sending data back to DM requires using hrefs.
```
location.href = '?src=12345&param=1'
```
If you're familiar with the href syntax of BYOND topic calls, then perhaps this doesn't surprise you, but this API artificially limits you to sending 2048 characters of string-typed data; you need to reinvent the wheel if you want to send something more complex than strings. It differs from the way you send messages from DM. And it's very hard to read as well.
Thankfully, TGUI implements a very robust protocol that makes this slightly less of an eye sore and very convenient to use in the long run.
### Message structure
```ts
{
type: string;
payload?: any;
// ...
}
```
Each message always has a **type**, which is usually (but not always) the first argument on all message sending functions. The next property is the **payload**, which contains all the data sent in the message.
You can think of it in these terms:
- **type** - function name
- **payload** - function arguments
Of course we're not working with functions here, but hopefully this analogy makes the concept easier to understand.
Finally, message can contain custom properties, and how you use them is *completely up to you*. They have an important limitation - all additional properties are string-typed, and require you to use a slightly more verbose API for sending them (more about it in the next section).
```js
Byond.sendMessage({
type: 'click',
payload: { buttonId: 1 },
popup_section: 'left',
});
```
### DM ➡ JS
To send a message from DM, you can use the `window.send_message()` proc.
```dm
window.send_message("alert", list(
text = "Hello, world!",
))
```
To receive it in JS, you have two different syntaxes. First one is the most verbose one, but allows receiving all types of messages, and deciding what to do via `if` conditions.
> NOTE: We're using ECMAScript 5 syntax here, because this is the version that is supported by IE 11 natively without any additional compilation. If you're coding in a compiled environment (TGUI/Webpack), then feel free to use arrow functions and other fancy syntaxes.
```js
Byond.subscribe(function (type, payload) {
if (type === 'alert') {
window.alert(payload.text);
return;
}
if (type === 'other') {
// ...
return;
}
// ...
});
```
Second one is more compact, because it already filters messages by type and passes the payload directly to the callback.
```js
Byond.subscribeTo('alert', function (payload) {
window.alert(payload.text);
});
```
### JS ➡ DM
To send a message from JS, you can use the `Byond.sendMessage()` function.
```js
Byond.sendMessage('click', {
button: 'explode-mech',
});
```
To receive it in DM, you must register a delegate proc (callback) that will receive the messages (usually called `on_message`), and handle the message in that proc.
```dm
/datum/my_object/proc/initialize()
// ...
window.subscribe(src, .proc/on_message)
/datum/my_object/proc/on_message(type, payload)
if (type == "click")
process_button_click(payload["button"])
return
```
**Advanced variant**
You can send messages with custom fields in case if you want to bypass JSON serialization of the **payload**. Not sending the **payload** is a little bit faster if you send a lot of messages (because BYOND is slow in general with proc calls, especially `json_decode`). All raw message fields are available in the third argument `href_list`.
```js
Byond.sendMessage({
type: "something",
ref: "[0x12345678]",
});
```
```dm
/datum/my_object/proc/on_message(type, payload, href_list)
if (type == "something")
process_something(locate(href_list["ref"]))
return
```
## BYOND Skin API
There is a full assortment of BYOND client-side features that you can access via the `Byond` API object.
Full reference of the `Byond` API object is here: [global.d.ts](../global.d.ts). It's a global type definition file, which provides auto-completion in VSCode when coding TGUI interfaces. When writing custom popups outside of TGUI, autocompletion doesn't work, so you might need to peek into this file sometimes.
Here's the summary of what it has.
- `Byond.winget()` - Returns a property of a skin element. This is an async function call, more on that later.
- `Byond.winset()` - Sets a property of a skin element.
- `Byond.topic()` - Makes a Topic call to the server. Similar to `sendMessage`, but all topic calls are native to BYOND, string typed and processed in `/client/Topic()` proc.
- `Byond.command()` - Runs a command on the client, as if you typed it into the command bar yourself. Can be any verb, or a special client-side command, such as `.output`.
> As of now, `Byond.winget()` requires a Promise polyfill, which is only available in compiled TGUI, but not in plain popups, and if you try using it, you'll get a bluescreen error. If you'd like to have winget in non-compiled contexts, then ping maintainers on Discord to request this feature.
When working with `winset` and `winget`, it can be very useful to consult [BYOND 5.0 controls and parameters guide](https://secure.byond.com/docs/ref/skinparams.html) to figure out what you can control in the BYOND client. Via these controls and parameters, you can do many interesting things, such as dynamically define BYOND macros, or show/hide and reposition various skin elements.
Another source of information is the official [BYOND Reference](https://secure.byond.com/docs/ref/info.html#/{skin}), which is a much larger, but a more comprehensive doc.
Id of the current tgui window can be accessed via `Byond.windowId`, and below in an example of changing its `size`.
```js
Byond.winset(Byond.windowId, {
size: '1280x640',
});
```
Id of the main SS13 window is `'mainwindow'`, as defined in [skin.dmf](../../interface/skin.dmf).
Little known feature, but you can also get non-UI parameters on the client by using a `null` id.
```js
// Fetch URL of a server client is currently connected to
Byond.winget(null, 'url').then((serverUrl) => {
// Connect to this server
Byond.call(serverUrl);
// Close our client because it is now connecting in background
Byond.command('.quit');
});
```
+42 -13
View File
@@ -22,12 +22,29 @@ declare global {
export default content;
}
type TguiMessage = {
type: string;
payload?: any;
[key: string]: any;
};
type ByondType = {
/**
* ID of the Byond window this script is running on.
* Can be used as a parameter to winget/winset.
*/
windowId: string;
/**
* True if javascript is running in BYOND.
*/
IS_BYOND: boolean;
/**
* Version of Trident engine of Internet Explorer. Null if N/A.
*/
TRIDENT: number | null;
/**
* True if browser is IE8 or lower.
*/
@@ -80,14 +97,14 @@ declare global {
*
* Returns a promise with a key-value object containing all properties.
*/
winget(id: string): Promise<object>;
winget(id: string | null): Promise<object>;
/**
* Retrieves all properties of the BYOND skin element.
*
* Returns a promise with a key-value object containing all properties.
*/
winget(id: string, propName: '*'): Promise<object>;
winget(id: string | null, propName: '*'): Promise<object>;
/**
* Retrieves an exactly one property of the BYOND skin element,
@@ -95,7 +112,7 @@ declare global {
*
* Returns a promise with the value of that property.
*/
winget(id: string, propName: string): Promise<any>;
winget(id: string | null, propName: string): Promise<any>;
/**
* Retrieves multiple properties of the BYOND skin element,
@@ -103,30 +120,47 @@ declare global {
*
* Returns a promise with a key-value object containing listed properties.
*/
winget(id: string, propNames: string[]): Promise<object>;
winget(id: string | null, propNames: string[]): Promise<object>;
/**
* Assigns properties to BYOND skin elements.
* Assigns properties to BYOND skin elements in bulk.
*/
winset(props: object): void;
/**
* Assigns properties to the BYOND skin element.
*/
winset(id: string, props: object): void;
winset(id: string | null, props: object): void;
/**
* Sets a property on the BYOND skin element to a certain value.
*/
winset(id: string, propName: string, propValue: any): void;
winset(id: string | null, propName: string, propValue: any): void;
/**
* Parses BYOND JSON.
*
* Uses a special encoding to preverse Infinity and NaN.
* Uses a special encoding to preserve `Infinity` and `NaN`.
*/
parseJson(text: string): any;
/**
* Sends a message to `/datum/tgui_window` which hosts this window instance.
*/
sendMessage(type: string, payload?: any): void;
sendMessage(message: TguiMessage): void;
/**
* Subscribe to incoming messages that were sent from `/datum/tgui_window`.
*/
subscribe(listener: (type: string, payload: any) => void): void;
/**
* Subscribe to incoming messages *of some specific type*
* that were sent from `/datum/tgui_window`.
*/
subscribeTo(type: string, listener: (payload: any) => void): void;
/**
* Loads a stylesheet into the document.
*/
@@ -145,11 +179,6 @@ declare global {
const Byond: ByondType;
interface Window {
/**
* ID of the Byond window this script is running on.
* Should be used as a parameter to winget/winset.
*/
__windowId__: string;
Byond: ByondType;
}
+3 -12
View File
@@ -68,20 +68,11 @@ const setupApp = () => {
setupPanelFocusHacks();
captureExternalLinks();
// Subscribe for Redux state updates
// Re-render UI on store updates
store.subscribe(renderApp);
// Subscribe for bankend updates
window.update = msg => store.dispatch(Byond.parseJson(msg));
// Process the early update queue
while (true) {
const msg = window.__updateQueue__.shift();
if (!msg) {
break;
}
window.update(msg);
}
// Dispatch incoming messages as store actions
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
// Unhide the panel
Byond.winset('output', {
+1 -5
View File
@@ -4,7 +4,6 @@
* @license MIT
*/
import { sendMessage } from 'tgui/backend';
import { pingFail, pingSuccess } from './actions';
import { PING_INTERVAL, PING_QUEUE_SIZE, PING_TIMEOUT } from './constants';
@@ -23,10 +22,7 @@ export const pingMiddleware = store => {
}
const ping = { index, sentAt: Date.now() };
pings[index] = ping;
sendMessage({
type: 'ping',
payload: { index },
});
Byond.sendMessage('ping', { index });
index = (index + 1) % PING_QUEUE_SIZE;
};
return next => action => {
+2 -9
View File
@@ -4,7 +4,6 @@
* @license MIT
*/
import { sendMessage } from 'tgui/backend';
import { storage } from 'common/storage';
import { createLogger } from 'tgui/logging';
@@ -34,14 +33,8 @@ export const telemetryMiddleware = store => {
logger.debug('sending');
const limits = payload?.limits || {};
// Trim connections according to the server limit
const connections = telemetry.connections
.slice(0, limits.connections);
sendMessage({
type: 'telemetry',
payload: {
connections,
},
});
const connections = telemetry.connections.slice(0, limits.connections);
Byond.sendMessage('telemetry', { connections });
return;
}
// Keep telemetry up to date
+61
View File
@@ -0,0 +1,61 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
/* eslint-disable */
(function () {
'use strict';
// Necessary polyfill to make Webpack code splitting work on IE8
if (!Function.prototype.bind) (function () {
var slice = Array.prototype.slice;
Function.prototype.bind = function () {
var thatFunc = this, thatArg = arguments[0];
var args = slice.call(arguments, 1);
if (typeof thatFunc !== 'function') {
// closest thing possible to the ECMAScript 5
// internal IsCallable function
throw new TypeError('Function.prototype.bind - ' +
'what is trying to be bound is not callable');
}
return function () {
var funcArgs = args.concat(slice.call(arguments))
return thatFunc.apply(thatArg, funcArgs);
};
};
})();
if (!Array.prototype['forEach']) {
Array.prototype.forEach = function (callback, thisArg) {
if (this == null) {
throw new TypeError('Array.prototype.forEach called on null or undefined');
}
var T, k;
var O = Object(this);
var len = O.length >>> 0;
if (typeof callback !== "function") {
throw new TypeError(callback + ' is not a function');
}
if (arguments.length > 1) {
T = thisArg;
}
k = 0;
while (k < len) {
var kValue;
if (k in O) {
kValue = O[k];
callback.call(T, kValue, k, O);
}
k++;
}
};
}
// Inferno needs Int32Array, and it is not covered by core-js.
if (!window.Int32Array) {
window.Int32Array = Array;
}
})();
+3 -5
View File
@@ -4,14 +4,12 @@
* @license MIT
*/
// NOTE: There are numbered polyfills, which are baked and injected directly
// into `tgui.html`. See how they're baked in `package.json`.
import 'core-js/es';
import 'core-js/web/immediate';
import 'core-js/web/queue-microtask';
import 'core-js/web/timers';
import 'regenerator-runtime/runtime';
import './html5shiv';
import './ie8';
import './dom4';
import './css-om';
import './inferno';
import 'unfetch/polyfill';
-10
View File
@@ -1,10 +0,0 @@
/**
* @file
* @copyright 2020 Aleksej Komarov
* @license MIT
*/
// Inferno needs Int32Array, and it is not covered by core-js.
if (!window.Int32Array) {
window.Int32Array = Array;
}
+6
View File
@@ -2,9 +2,15 @@
"private": true,
"name": "tgui-polyfill",
"version": "4.3.0",
"scripts": {
"tgui-polyfill:build": "terser 00-html5shiv.js 01-ie8.js 02-dom4.js 03-css-om.js 10-misc.js --ie8 -f ascii_only,comments=false -o ../../public/tgui-polyfill.min.js"
},
"dependencies": {
"core-js": "^3.22.5",
"regenerator-runtime": "^0.13.9",
"unfetch": "^4.2.0"
},
"devDependencies": {
"terser": "^5.13.1"
}
}
+11 -40
View File
@@ -134,19 +134,15 @@ export const backendMiddleware = store => {
}
if (type === 'ping') {
sendMessage({
type: 'pingReply',
});
Byond.sendMessage('pingReply');
return;
}
if (type === 'backend/suspendStart' && !suspendInterval) {
logger.log(`suspending (${window.__windowId__})`);
logger.log(`suspending (${Byond.windowId})`);
// Keep sending suspend messages until it succeeds.
// It may fail multiple times due to topic rate limiting.
const suspendFn = () => sendMessage({
type: 'suspend',
});
const suspendFn = () => Byond.sendMessage('suspend');
suspendFn();
suspendInterval = setInterval(suspendFn, 2000);
}
@@ -155,7 +151,7 @@ export const backendMiddleware = store => {
suspendRenderer();
clearInterval(suspendInterval);
suspendInterval = undefined;
Byond.winset(window.__windowId__, {
Byond.winset(Byond.windowId, {
'is-visible': false,
});
setImmediate(() => focusMap());
@@ -171,7 +167,7 @@ export const backendMiddleware = store => {
else if (fancyState !== fancy) {
logger.log('changing fancy mode to', fancy);
fancyState = fancy;
Byond.winset(window.__windowId__, {
Byond.winset(Byond.windowId, {
titlebar: !fancy,
'can-resize': !fancy,
});
@@ -195,7 +191,7 @@ export const backendMiddleware = store => {
if (suspended) {
return;
}
Byond.winset(window.__windowId__, {
Byond.winset(Byond.windowId, {
'is-visible': true,
});
perf.mark('resume/finish');
@@ -210,25 +206,6 @@ export const backendMiddleware = store => {
};
};
/**
* Sends a message to /datum/tgui_window.
*/
export const sendMessage = (message: any = {}) => {
const { payload, ...rest } = message;
const data: any = {
// 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.
@@ -242,10 +219,7 @@ export const sendAct = (action: string, payload: object = {}) => {
logger.error(`Payload for act() must be an object, got this:`, payload);
return;
}
sendMessage({
type: 'act/' + action,
payload,
});
Byond.sendMessage('act/' + action, payload);
};
type BackendState<TData> = {
@@ -273,7 +247,7 @@ type BackendState<TData> = {
shared: Record<string, any>,
suspending: boolean,
suspended: boolean,
}
};
/**
* Selects a backend-related slice of Redux state
@@ -283,12 +257,9 @@ export const selectBackend = <TData>(state: any): BackendState<TData> => (
);
/**
* A React hook (sort of) for getting tgui state and related functions.
* Get data from tgui backend.
*
* This is supposed to be replaced with a real React Hook, which can only
* be used in functional components.
*
* You can make
* Includes the `act` function for performing DM actions.
*/
export const useBackend = <TData>(context: any) => {
const { store } = context;
@@ -371,7 +342,7 @@ export const useSharedState = <T>(
return [
sharedState,
nextState => {
sendMessage({
Byond.sendMessage({
type: 'setSharedState',
key,
value: JSON.stringify(
+7 -6
View File
@@ -54,18 +54,19 @@ window.addEventListener('beforeunload', () => {
});
/**
* Get the bounding box of the DOM element.
* Get the bounding box of the DOM element in display-pixels.
*/
const getBoundingBox = element => {
const pixelRatio = window.devicePixelRatio ?? 1;
const rect = element.getBoundingClientRect();
return {
pos: [
rect.left,
rect.top,
rect.left * pixelRatio,
rect.top * pixelRatio,
],
size: [
rect.right - rect.left,
rect.bottom - rect.top,
(rect.right - rect.left) * pixelRatio,
(rect.bottom - rect.top) * pixelRatio,
],
};
};
@@ -114,7 +115,7 @@ export class ByondUi extends Component {
const box = getBoundingBox(this.containerRef.current);
logger.debug('bounding box', box);
this.byondUiElement.render({
parent: window.__windowId__,
parent: Byond.windowId,
...params,
pos: box.pos[0] + ',' + box.pos[1],
size: box.size[0] + 'x' + box.size[1],
+7 -9
View File
@@ -1,10 +1,11 @@
import { createPopper, OptionsGeneric } from "@popperjs/core";
import { Component, findDOMfromVNode, InfernoNode, render } from "inferno";
// import { createPopper, OptionsGeneric } from "@popperjs/core";
import { createPopper, OptionsGeneric } from '@popperjs/core';
import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno';
type PopperProps = {
popperContent: InfernoNode;
options?: Partial<OptionsGeneric<unknown>>;
additionalStyles?: CSSProperties,
additionalStyles?: CSSProperties;
};
export class Popper extends Component<PopperProps> {
@@ -20,12 +21,9 @@ export class Popper extends Component<PopperProps> {
}
componentDidMount() {
const {
additionalStyles,
options,
} = this.props;
const { additionalStyles, options } = this.props;
this.renderedContent = document.createElement("div");
this.renderedContent = document.createElement('div');
if (additionalStyles) {
for (const [attribute, value] of Object.entries(additionalStyles)) {
this.renderedContent.style[attribute] = value;
@@ -47,7 +45,7 @@ export class Popper extends Component<PopperProps> {
// immediately if this internal variable is removed.
findDOMfromVNode(this.$LI, true),
this.renderedContent,
options,
options
);
});
}
+2 -2
View File
@@ -44,7 +44,7 @@ export const relayMiddleware = store => {
if (externalBrowser) {
devServer.subscribe(msg => {
const { type, payload } = msg;
if (type === 'relay' && payload.windowId === window.__windowId__) {
if (type === 'relay' && payload.windowId === Byond.windowId) {
store.dispatch({
...payload.action,
relayed: true,
@@ -70,7 +70,7 @@ export const relayMiddleware = store => {
devServer.sendMessage({
type: 'relay',
payload: {
windowId: window.__windowId__,
windowId: Byond.windowId,
action,
},
});
+53 -43
View File
@@ -5,12 +5,13 @@
*/
import { storage } from 'common/storage';
import { vecAdd, vecInverse, vecMultiply, vecScale } from 'common/vector';
import { vecAdd, vecSubtract, vecMultiply, vecScale } from 'common/vector';
import { createLogger } from './logging';
const logger = createLogger('drag');
const pixelRatio = window.devicePixelRatio ?? 1;
let windowKey = window.__windowId__;
let windowKey = Byond.windowId;
let dragging = false;
let resizing = false;
let screenOffset = [0, 0];
@@ -24,37 +25,37 @@ export const setWindowKey = key => {
windowKey = key;
};
export const getWindowPosition = () => [
window.screenLeft,
window.screenTop,
const getWindowPosition = () => [
window.screenLeft * pixelRatio,
window.screenTop * pixelRatio,
];
export const getWindowSize = () => [
window.innerWidth,
window.innerHeight,
const getWindowSize = () => [
window.innerWidth * pixelRatio,
window.innerHeight * pixelRatio,
];
export const setWindowPosition = vec => {
const setWindowPosition = vec => {
const byondPos = vecAdd(vec, screenOffset);
return Byond.winset(window.__windowId__, {
return Byond.winset(Byond.windowId, {
pos: byondPos[0] + ',' + byondPos[1],
});
};
export const setWindowSize = vec => {
return Byond.winset(window.__windowId__, {
const setWindowSize = vec => {
return Byond.winset(Byond.windowId, {
size: vec[0] + 'x' + vec[1],
});
};
export const getScreenPosition = () => [
const getScreenPosition = () => [
0 - screenOffset[0],
0 - screenOffset[1],
];
export const getScreenSize = () => [
window.screen.availWidth,
window.screen.availHeight,
const getScreenSize = () => [
window.screen.availWidth * pixelRatio,
window.screen.availHeight * pixelRatio,
];
/**
@@ -83,7 +84,7 @@ const touchRecents = (recents, touchedItem, limit = 50) => {
return [nextRecents, trimmedItem];
};
export const storeWindowGeometry = async () => {
const storeWindowGeometry = async () => {
logger.log('storing geometry');
const geometry = {
pos: getWindowPosition(),
@@ -106,14 +107,19 @@ export const recallWindowGeometry = async (options = {}) => {
if (geometry) {
logger.log('recalled geometry:', geometry);
}
// options.pos is assumed to already be in display-pixels
let pos = geometry?.pos || options.pos;
let size = options.size;
// Convert size from css-pixels to display-pixels
if (size) {
size = [
size[0] * pixelRatio,
size[1] * pixelRatio,
];
}
// Wait until screen offset gets resolved
await screenOffsetPromise;
const areaAvailable = [
window.screen.availWidth,
window.screen.availHeight,
];
const areaAvailable = getScreenSize();
// Set window size
if (size) {
// Constraint size to not exceed available screen area.
@@ -143,10 +149,12 @@ export const recallWindowGeometry = async (options = {}) => {
export const setupDrag = async () => {
// Calculate screen offset caused by the windows taskbar
screenOffsetPromise = Byond.winget(window.__windowId__, 'pos')
let windowPosition = getWindowPosition();
screenOffsetPromise = Byond.winget(Byond.windowId, 'pos')
.then(pos => [
pos.x - window.screenLeft,
pos.y - window.screenTop,
pos.x - windowPosition[0],
pos.y - windowPosition[1],
]);
screenOffset = await screenOffsetPromise;
logger.debug('screen offset', screenOffset);
@@ -179,10 +187,10 @@ const constraintPosition = (pos, size) => {
export const dragStartHandler = event => {
logger.log('drag start');
dragging = true;
dragPointOffset = [
window.screenLeft - event.screenX,
window.screenTop - event.screenY,
];
let windowPosition = getWindowPosition();
dragPointOffset = vecSubtract(
[event.screenX, event.screenY],
getWindowPosition());
// Focus click target
event.target?.focus();
document.addEventListener('mousemove', dragMoveHandler);
@@ -204,7 +212,7 @@ const dragMoveHandler = event => {
return;
}
event.preventDefault();
setWindowPosition(vecAdd(
setWindowPosition(vecSubtract(
[event.screenX, event.screenY],
dragPointOffset));
};
@@ -213,14 +221,10 @@ export const resizeStartHandler = (x, y) => event => {
resizeMatrix = [x, y];
logger.log('resize start', resizeMatrix);
resizing = true;
dragPointOffset = [
window.screenLeft - event.screenX,
window.screenTop - event.screenY,
];
initialSize = [
window.innerWidth,
window.innerHeight,
];
dragPointOffset = vecSubtract(
[event.screenX, event.screenY],
getWindowPosition());
initialSize = getWindowSize();
// Focus click target
event.target?.focus();
document.addEventListener('mousemove', resizeMoveHandler);
@@ -242,13 +246,19 @@ const resizeMoveHandler = event => {
return;
}
event.preventDefault();
size = vecAdd(initialSize, vecMultiply(resizeMatrix, vecAdd(
const currentOffset = vecSubtract(
[event.screenX, event.screenY],
vecInverse([window.screenLeft, window.screenTop]),
dragPointOffset,
[1, 1])));
getWindowPosition());
const delta = vecSubtract(
currentOffset,
dragPointOffset);
// Extra 1x1 area is added to ensure the browser can see the cursor
size = vecAdd(
initialSize,
vecMultiply(resizeMatrix, delta),
[1, 1]);
// Sane window size values
size[0] = Math.max(size[0], 150);
size[1] = Math.max(size[1], 50);
size[0] = Math.max(size[0], 150 * pixelRatio);
size[1] = Math.max(size[1], 50 * pixelRatio);
setWindowSize(size);
};
+1 -1
View File
@@ -19,7 +19,7 @@ export const focusMap = () => {
* Moves focus to the browser window.
*/
export const focusWindow = () => {
Byond.winset(window.__windowId__, {
Byond.winset(Byond.windowId, {
focus: true,
});
};
+3 -12
View File
@@ -53,20 +53,11 @@ const setupApp = () => {
setupHotKeys();
captureExternalLinks();
// Subscribe for state updates
// Re-render UI on store updates
store.subscribe(renderApp);
// Dispatch incoming messages
window.update = msg => store.dispatch(Byond.parseJson(msg));
// Process the early update queue
while (true) {
const msg = window.__updateQueue__.shift();
if (!msg) {
break;
}
window.update(msg);
}
// Dispatch incoming messages as store actions
Byond.subscribe((type, payload) => store.dispatch({ type, payload }));
// Enable hot module reloading
if (module.hot) {
+1 -1
View File
@@ -28,7 +28,7 @@ export class Window extends Component {
if (suspended) {
return;
}
Byond.winset(window.__windowId__, {
Byond.winset(Byond.windowId, {
'can-close': Boolean(canClose),
});
logger.log('mounting');
+1 -3
View File
@@ -39,9 +39,7 @@ export const captureExternalLinks = () => {
url = 'https://' + url;
}
// Open the link
Byond.topic({
tgui: 1,
window_id: window.__windowId__,
Byond.sendMessage({
type: 'openLink',
url,
});
+1 -3
View File
@@ -32,9 +32,7 @@ const log = (level, ns, ...args) => {
.filter(value => value)
.join(' ')
+ '\nUser Agent: ' + navigator.userAgent;
Byond.topic({
tgui: 1,
window_id: window.__windowId__,
Byond.sendMessage({
type: 'log',
ns,
message: logEntry,
@@ -16,7 +16,7 @@ export const meta = {
const Story = (props, context) => {
const [code, setCode] = useLocalState(context,
'byondUiEvalCode',
`Byond.winset('${window.__windowId__}', {\n 'is-visible': true,\n})`);
`Byond.winset('${Byond.windowId}', {\n 'is-visible': true,\n})`);
return (
<>
<Section title="Button">
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+133 -74
View File
@@ -4,18 +4,11 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<!-- Inlined data -->
<!-- Inlined metadata -->
<meta id="tgui:windowId" content="[tgui:windowId]">
<!-- Early setup -->
<script type="text/javascript">
// Read window id into a global
window.__windowId__ = document
.getElementById('tgui:windowId')
.getAttribute('content');
if (window.__windowId__ === '[' + 'tgui:windowId' + ']') {
window.__windowId__ = null;
}
(function () {
// Utility functions
@@ -31,13 +24,27 @@ if (window.__windowId__ === '[' + 'tgui:windowId' + ']') {
}
return target;
};
var parseMetaTag = function (name) {
var content = document.getElementById(name).getAttribute('content');
if (content === '[' + name + ']') {
return null;
}
return content;
};
// BYOND API object
// ------------------------------------------------------
var Byond = window.Byond = {};
// Expose inlined metadata
Byond.windowId = parseMetaTag('tgui:windowId');
// Backwards compatibility
window.__windowId__ = Byond.windowId;
// Trident engine version
var tridentVersion = (function () {
Byond.TRIDENT = (function () {
var groups = navigator.userAgent.match(/Trident\/(\d+).+?;/i);
var majorVersion = groups && groups[1];
return majorVersion
@@ -46,17 +53,17 @@ if (window.__windowId__ === '[' + 'tgui:windowId' + ']') {
})();
// Basic checks to detect whether this page runs in BYOND
var isByond = (tridentVersion !== null || window.cef_to_byond)
var isByond = (Byond.TRIDENT !== null || window.cef_to_byond)
&& location.hostname === '127.0.0.1'
&& location.pathname.indexOf('/tmp') === 0
&& location.search !== '?external';
// Version constants
Byond.IS_BYOND = isByond;
Byond.IS_LTE_IE8 = tridentVersion !== null && tridentVersion <= 4;
Byond.IS_LTE_IE9 = tridentVersion !== null && tridentVersion <= 5;
Byond.IS_LTE_IE10 = tridentVersion !== null && tridentVersion <= 6;
Byond.IS_LTE_IE11 = tridentVersion !== null && tridentVersion <= 7;
Byond.IS_LTE_IE8 = Byond.TRIDENT !== null && Byond.TRIDENT <= 4;
Byond.IS_LTE_IE9 = Byond.TRIDENT !== null && Byond.TRIDENT <= 5;
Byond.IS_LTE_IE10 = Byond.TRIDENT !== null && Byond.TRIDENT <= 6;
Byond.IS_LTE_IE11 = Byond.TRIDENT !== null && Byond.TRIDENT <= 7;
// Callbacks for asynchronous calls
Byond.__callbacks__ = [];
@@ -143,6 +150,9 @@ if (window.__windowId__ === '[' + 'tgui:windowId' + ']') {
};
Byond.winget = function (id, propName) {
if (id === null) {
id = '';
}
var isArray = propName instanceof Array;
var isSpecific = propName && propName !== '*' && !isArray;
var promise = Byond.callAsync('winget', {
@@ -158,7 +168,10 @@ if (window.__windowId__ === '[' + 'tgui:windowId' + ']') {
};
Byond.winset = function (id, propName, propValue) {
if (typeof id === 'object' && id !== null) {
if (id === null) {
id = '';
}
else if (typeof id === 'object') {
return Byond.call('winset', id);
}
var props = {};
@@ -181,6 +194,41 @@ if (window.__windowId__ === '[' + 'tgui:windowId' + ']') {
}
};
Byond.sendMessage = function (type, payload) {
var message = typeof type === 'string'
? { type: type, payload: payload }
: type;
// JSON-encode the payload
if (message.payload !== null && message.payload !== undefined) {
message.payload = JSON.stringify(message.payload);
}
// Append an identifying header
assign(message, {
tgui: 1,
window_id: Byond.windowId,
});
Byond.topic(message);
};
// This function exists purely for debugging, do not use it in code!
Byond.injectMessage = function (type, payload) {
window.update(JSON.stringify({ type: type, payload: payload }));
};
Byond.subscribe = function (listener) {
window.update.flushQueue(listener);
window.update.listeners.push(listener);
};
Byond.subscribeTo = function (type, listener) {
listener = function (_type, payload) {
if (_type === type) {
listener(payload);
}
};
window.update.listeners.push(listener);
window.update.flushQueue(listener);
};
// Asset loaders
// ------------------------------------------------------
@@ -312,7 +360,9 @@ if (window.__windowId__ === '[' + 'tgui:windowId' + ']') {
};
})();
// Global error handling
// Error handling
// ------------------------------------------------------
window.onerror = function (msg, url, line, col, error) {
// Proper stacktrace
var stack = error && error.stack;
@@ -341,7 +391,7 @@ window.onerror = function (msg, url, line, col, error) {
}
// Set window geometry
var setFatalErrorGeometry = function () {
Byond.winset(window.__windowId__, {
Byond.winset(Byond.windowId, {
titlebar: true,
size: '600x600',
'is-visible': true,
@@ -351,9 +401,7 @@ window.onerror = function (msg, url, line, col, error) {
setFatalErrorGeometry();
setInterval(setFatalErrorGeometry, 1000);
// Send logs to the game server
Byond.topic({
tgui: 1,
window_id: window.__windowId__,
Byond.sendMessage({
type: 'log',
fatal: 1,
message: stack,
@@ -382,61 +430,69 @@ window.__augmentStack__ = function (stack, error) {
return stack + '\nUser Agent: ' + navigator.userAgent;
};
// Early initialization
window.__updateQueue__ = [];
window.update = function (message) {
window.__updateQueue__.push(message);
// Incoming message handling
// ------------------------------------------------------
// Message handler
window.update = function (rawMessage) {
// Push onto the queue (active during initialization)
if (window.update.queueActive) {
window.update.queue.push(rawMessage);
return;
}
// Parse the message
var message = Byond.parseJson(rawMessage);
// Notify listeners
var listeners = window.update.listeners;
for (var i = 0; i < listeners.length; i++) {
listeners[i](message.type, message.payload);
}
};
Byond.topic({
tgui: 1,
window_id: window.__windowId__,
type: 'ready',
});
// Necessary polyfill to make Webpack code splitting work on IE8
if (!Function.prototype.bind) (function () {
var slice = Array.prototype.slice;
Function.prototype.bind = function () {
var thatFunc = this, thatArg = arguments[0];
var args = slice.call(arguments, 1);
if (typeof thatFunc !== 'function') {
// closest thing possible to the ECMAScript 5
// internal IsCallable function
throw new TypeError('Function.prototype.bind - ' +
'what is trying to be bound is not callable');
// Properties and variables of this specific handler
window.update.listeners = [];
window.update.queue = [];
window.update.queueActive = true;
window.update.flushQueue = function (listener) {
// Disable and clear the queue permanently on short delay
if (window.update.queueActive) {
window.update.queueActive = false;
if (window.setTimeout) {
window.setTimeout(function () {
window.update.queue = [];
}, 0);
}
return function () {
var funcArgs = args.concat(slice.call(arguments))
return thatFunc.apply(thatArg, funcArgs);
};
};
})();
}
// Process queued messages on provided listener
var queue = window.update.queue;
for (var i = 0; i < queue.length; i++) {
var message = Byond.parseJson(queue[i]);
listener(message.type, message.payload);
}
};
if (!Array.prototype['forEach']) {
Array.prototype.forEach = function (callback, thisArg) {
if (this == null) {
throw new TypeError('Array.prototype.forEach called on null or undefined');
}
var T, k;
var O = Object(this);
var len = O.length >>> 0;
if (typeof callback !== "function") {
throw new TypeError(callback + ' is not a function');
}
if (arguments.length > 1) {
T = thisArg;
}
k = 0;
while (k < len) {
var kValue;
if (k in O) {
kValue = O[k];
callback.call(T, kValue, k, O);
window.replaceHtml = function (inline_html) {
var children = document.body.childNodes;
for (var i = 0; i < children.length; i++) {
if (children[i].nodeValue == " tgui:inline-html-start ") {
while (children[i].nodeValue != " tgui:inline-html-end ") {
children[i].remove();
}
k++;
children[i].remove();
}
};
}
}
document.body.insertAdjacentHTML(
"afterbegin",
"<!-- tgui:inline-html-start -->"
+ inline_html
+ "<!-- tgui:inline-html-end -->"
);
};
// Signal tgui that we're ready to receive updates
Byond.sendMessage('ready');
</script>
<style>
@@ -524,16 +580,19 @@ if (!Array.prototype['forEach']) {
100% { top: -15px; }
}
</style>
<!-- tgui:inline-css -->
</head>
<body>
<!-- Inline assets -->
<!-- tgui:inline-polyfill -->
<!-- tgui:assets -->
<!-- tgui:inline-html-start -->
<!-- tgui:inline-html -->
<!-- tgui:inline-html-end -->
<!-- tgui:inline-js -->
<!-- Inline HTML -->
<!-- tgui:html -->
<!-- tgui container -->
<!-- Root element for tgui interfaces -->
<div id="react-root"></div>
<!-- Fatal error container -->
+2 -1
View File
@@ -8623,7 +8623,7 @@ resolve@^2.0.0-next.3:
languageName: node
linkType: hard
"terser@npm:^5.7.2":
"terser@npm:^5.13.1, terser@npm:^5.7.2":
version: 5.13.1
resolution: "terser@npm:5.13.1"
dependencies:
@@ -8696,6 +8696,7 @@ resolve@^2.0.0-next.3:
dependencies:
core-js: ^3.22.5
regenerator-runtime: ^0.13.9
terser: ^5.13.1
unfetch: ^4.2.0
languageName: unknown
linkType: soft