From e62f4eb8e9bc271b0d2684dc32b3081530d40793 Mon Sep 17 00:00:00 2001 From: Ruslan Kovtun Date: Sun, 6 Mar 2022 09:23:54 +0200 Subject: [PATCH 1/3] Adds `Rect`, `ReactDOM` typings --- global.d.ts | 4 ++++ package.json | 4 ++++ tsconfig.json | 1 + 3 files changed, 9 insertions(+) diff --git a/global.d.ts b/global.d.ts index 44e15a0..463585b 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,10 +1,14 @@ import _ from 'lodash' import * as bitburner from "./NetscriptDefinitions"; +import React from 'react' +import ReactDOM from 'react-dom' export { }; declare global { const _: typeof _ + const React: typeof React + const ReactDOM: typeof ReactDOM interface NS extends bitburner.NS {} diff --git a/package.json b/package.json index 967d1e7..8b615f0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "devDependencies": { "@types/lodash": "^4.14.178", "@types/node": "^16.4.3", + "@types/react": "^17.0.21", + "@types/react-beautiful-dnd": "^13.1.2", + "@types/react-dom": "^17.0.13", + "@types/react-resizable": "^1.7.3", "@typescript-eslint/eslint-plugin": "^4.28.4", "@typescript-eslint/parser": "^4.28.4", "eslint": "^7.31.0", diff --git a/tsconfig.json b/tsconfig.json index d82f869..e556aea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "src/**/*", ], "compilerOptions": { + "jsx": "react", "module": "esnext", "target": "esnext", "moduleResolution": "node", From 191e9b5845f12f248d2957d3338a623e3286e96b Mon Sep 17 00:00:00 2001 From: Ruslan Kovtun Date: Mon, 9 May 2022 13:16:16 +0300 Subject: [PATCH 2/3] Adds very basic example of how to use React This example have to requirements and suppposed to approve ability to use React for your custom UI. It is not well suitable for real world problems. --- src/examples/hello-react.tsx | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/examples/hello-react.tsx diff --git a/src/examples/hello-react.tsx b/src/examples/hello-react.tsx new file mode 100644 index 0000000..d372f8c --- /dev/null +++ b/src/examples/hello-react.tsx @@ -0,0 +1,67 @@ +const POPUP_ID = 'hello-react-root' +const POPUP_HTML = ` +
+` +const DOC = eval(`document`) as Document + +interface IAppProps { + onClose: () => void, +} + +interface IAppState { + closed: boolean, +} + +class App extends React.Component { + override state: IAppState + constructor(props: IAppProps) { + console.log('Constructor called') + super(props) + this.state = { closed: false } + this.componentDidCatch = (err, errInfo) => console.log('- ERROR: ', err, errInfo) + this.componentDidMount = () => console.log('- AppRoot mounted') + this.componentDidUpdate = () => console.log('- AppRoot updated') + this.componentWillUnmount = () => console.log('- AppRoot will unmount') + this.close = this.close.bind(this) + } + override render(): React.ReactNode { + return this.state.closed ? <> : + <> + + Hello world + + } + close(): void { + console.log('AppRoot.close() called') + this.setState({ closed: true }, () => this.props.onClose()) + } +} + +export async function main(ns: NS) { + const thisScriptName = ns.getScriptName() + if (ns.ps().filter(info => info.filename === thisScriptName).length > 1) { + throw new Error("Double lauch") + return + } + if (!DOC.getElementById(POPUP_ID)) { + DOC.body.insertAdjacentHTML('beforeend', POPUP_HTML) + } + const root = DOC.getElementById(POPUP_ID) + if (root === null) { + throw new Error("Can't create root element") + return + } + await new Promise(resolve => ReactDOM.render( + , + root, + () => console.log('AppRoot rendered') + )) + ReactDOM.unmountComponentAtNode(root) + console.log('AppRoot closed') +} From d1c5e7bd47746978c3922e5a8029fd57c9932438 Mon Sep 17 00:00:00 2001 From: Ruslan Kovtun Date: Mon, 9 May 2022 14:21:52 +0300 Subject: [PATCH 3/3] Adds real world usage example It contains out of topic code but illustrates handier usage --- src/examples/common/autocomplete.ts | 46 ++++ src/examples/common/react-mini-lib.tsx | 301 +++++++++++++++++++++++++ src/examples/hacknet-upgrade.tsx | 115 ++++++++++ 3 files changed, 462 insertions(+) create mode 100644 src/examples/common/autocomplete.ts create mode 100644 src/examples/common/react-mini-lib.tsx create mode 100644 src/examples/hacknet-upgrade.tsx diff --git a/src/examples/common/autocomplete.ts b/src/examples/common/autocomplete.ts new file mode 100644 index 0000000..e0685c0 --- /dev/null +++ b/src/examples/common/autocomplete.ts @@ -0,0 +1,46 @@ +export class Arg { + private static space = ' ' + // static unicodeSpace = '\u2002' + private static unicodeSpace = '_' + private static escape(val: string): string { + return val.replaceAll(Arg.space, Arg.unicodeSpace) + } + private static unescape(val: T): T { + return typeof val === 'string' + ? val.replaceAll(Arg.unicodeSpace, Arg.space) as any + : val + } + static wrap(autocomplete: (data: AutocompleteData, args: string[]) => any[]) { + return (data: AutocompleteData, args: string[]) => { + return autocomplete(data, args).map(Arg.escape) + } + } + static unwrap(flags: T): T { + for (const [key, value] of Object.entries(flags)) { + if (typeof value === 'string') { + (flags as Record)[key] = Arg.unescape(value) + } + else if (Array.isArray(value)) { + (flags as Record)[key] = value.map(Arg.unescape) + } + } + return flags + } +} + +enum Flag { + Tail = 'tail', + Args = '_', +} + +export interface IBaseFlagsConfig { + [Flag.Args]: string[], +} + +export function flagToArgument(flag: string): string { + return `--${flag}` +} + +export const baseFlagsConfig: AutocompleteConfig = [ + [Flag.Tail, false], +] diff --git a/src/examples/common/react-mini-lib.tsx b/src/examples/common/react-mini-lib.tsx new file mode 100644 index 0000000..c7a47b0 --- /dev/null +++ b/src/examples/common/react-mini-lib.tsx @@ -0,0 +1,301 @@ +import { Arg, IBaseFlagsConfig } from "common/autocomplete" + +export const WIN = eval(`window`) as Window & {} +export const DOC = eval(`document`) as Document + +export function makeRootElement({ zIndex }: { zIndex?: number } = {}): HTMLDivElement { + const props: React.CSSProperties = { + zIndex: zIndex ?? 100500, + position: 'fixed', + left: 0, top: 0, + width: 0, height: 0, + } + const root = DOC.createElement('div') + root.classList.add(P_CLASS_NAMES) + for (const [name, value] of Object.entries(props) as any) { + root.style[name] = value + } + DOC.body.append(root) + return root +} + +export type AppProps = React.PropsWithChildren<{ ns: NS, onClose: () => void }> +export type AppPropsWith = AppProps & { data: IFlagsConfig } +export type AppComponent

= React.FunctionComponent

| React.ComponentClass

+ +export async function asyncRun(ns: NS, App: AppComponent): Promise; +export async function asyncRun( + ns: NS, App: AppComponent>, flagsConfig: AutocompleteConfig): Promise; +export async function asyncRun( + ns: NS, + appUnion: AppComponent | AppComponent>, + flagsConfig?: AutocompleteConfig, +): Promise { + const root = makeRootElement() + return new Promise(resolve => { + const App = appUnion as AppComponent + let props: AppProps | AppPropsWith = { ns, onClose: resolve } + if (flagsConfig !== undefined) { + props = { ...props, data: Arg.unwrap(ns.flags(flagsConfig)) } + } + ReactDOM.render(, root) + }).then(() => { + ReactDOM.unmountComponentAtNode(root) + root.remove() + }) +} + +export const enum ButtonClass { + Primary = 'css-13ak5e0', + Disabled = 'Mui-disabled', + Exit = 'css-83reht', +} + +function addUserSelectStyles() { + var styleEl = DOC.getElementById('react-draggable-style-el') + if (!styleEl) { + styleEl = DOC.createElement('style') + styleEl.id = 'react-draggable-style-el' + styleEl.innerHTML = '.react-draggable-transparent-selection *::-moz-selection {all: inherit}\n' + styleEl.innerHTML += '.react-draggable-transparent-selection *::selection {all: inherit}\n' + DOC.head.appendChild(styleEl) + } + DOC.body.classList.add('react-draggable-transparent-selection') +} + +function removeUserSelectStyles() { + try { + DOC.body.classList.remove('react-draggable-transparent-selection') + // Remove selection caused by scroll, unless it's a focused input + // (we use doc.defaultView in case we're in an iframe) + var selection = (DOC.defaultView || WIN).getSelection() + if (selection && selection.type !== 'Caret') { + selection.removeAllRanges() + } + } catch (e) { } +} + +type DraggableData = { + node: HTMLElement, + x: number, y: number, + deltaX: number, deltaY: number, + lastX: number, lastY: number, +} + +type DraggableEventHandler = (e: MouseEvent, data: DraggableData) => void + +type DraggableCoreState = { + dragging: boolean, + lastX: number, + lastY: number, +} + +type DraggableCoreProps = { + children: React.ReactElement, + onStart: DraggableEventHandler, + onDrag: DraggableEventHandler, + onStop: DraggableEventHandler, + onMouseDown?: (e: MouseEvent) => void, +} + +class DraggableCore extends React.PureComponent { + constructor(props: DraggableCoreProps) { + super(props) + this.state = { + dragging: false, + lastX: NaN, + lastY: NaN, + } + this.handleDragStart = this.handleDragStart.bind(this) + this.handleDrag = this.handleDrag.bind(this) + this.handleDragStop = this.handleDragStop.bind(this) + } + createCoreData(x: number, y: number): DraggableData { + const { lastX, lastY } = this.state + const node = DOC.body + if (isNaN(lastX) || isNaN(lastY)) { + return { + node, x, y, + deltaX: 0, + deltaY: 0, + lastX: x, + lastY: y, + } + } + return { + node, lastX, lastY, x, y, + deltaX: x - lastX, + deltaY: y - lastY, + } + } + onMouseDown(ev: React.MouseEvent): void { + this.handleDragStart(ev) + } + handleDragStart(ev: React.MouseEvent | MouseEvent): void { + addUserSelectStyles() + this.props.onMouseDown && this.props.onMouseDown(ev as any) + const x = ev.clientX + DOC.body.scrollLeft + const y = ev.clientY + DOC.body.scrollTop + const coreEvent = this.createCoreData(x, y) // for custom event handling + this.props.onStart(ev as any, coreEvent) + this.setState({ + dragging: true, + lastX: x, + lastY: y, + }) + DOC.addEventListener('mousemove', this.handleDrag) + DOC.addEventListener('mouseup', this.handleDragStop) + } + handleDrag(ev: MouseEvent): void { + const x = ev.clientX + DOC.body.scrollLeft + const y = ev.clientY + DOC.body.scrollTop + const coreEvent = this.createCoreData(x, y) // for custom event handling + this.props.onDrag(ev, coreEvent) + const { lastX, lastY } = this.state + if (lastX !== x || lastY !== y) { + this.setState({ + lastX: x, + lastY: y, + }) + } + } + handleDragStop(ev: React.MouseEvent | MouseEvent): void { + if (!this.state.dragging) { + return + } + removeUserSelectStyles() + const x = ev.clientX + DOC.body.scrollLeft + const y = ev.clientY + DOC.body.scrollTop + const coreEvent = this.createCoreData(x, y) // for custom event handling + this.props.onStop(ev as any, coreEvent) + this.setState({ + dragging: false, + lastX: NaN, + lastY: NaN, + }) + DOC.removeEventListener('mousemove', this.handleDrag) + DOC.removeEventListener('mouseup', this.handleDragStop) + } + override render(): React.ReactElement { + return ( + React.cloneElement(React.Children.only(this.props.children), { + onMouseDown: this.handleDragStart, + onMouseUp: this.handleDragStop, + }) + ) + } +} + +type DraggableProps = { + children: React.ReactElement, + onStart?: DraggableEventHandler, + onDrag?: DraggableEventHandler, + onStop?: DraggableEventHandler, + onMouseDown?: (ev: MouseEvent) => void, +} + +type DraggableState = { + x: number, + y: number, + dragging: boolean, + dragged: boolean, +} + +export class Draggable extends React.PureComponent { + constructor(props: DraggableProps) { + super(props) + this.state = { + x: 0, y: 0, + dragging: false, + dragged: false, + } + this.onDrag = this.onDrag.bind(this) + this.onDragStart = this.onDragStart.bind(this) + this.onDragStop = this.onDragStop.bind(this) + } + createDraggableData(coreData: DraggableData) { + const { node, deltaX, deltaY } = coreData + return { + node, deltaX, deltaY, + x: this.state.x + coreData.deltaX, + y: this.state.y + coreData.deltaY, + lastX: this.state.x, + lastY: this.state.y + } + } + onDragStart(ev: MouseEvent, coreData: DraggableData) { + this.props.onStart && this.props.onStart(ev, this.createDraggableData(coreData)) + this.setState({ + dragging: true, + dragged: true, + }) + } + onDrag(ev: MouseEvent, coreData: DraggableData) { + if (!this.state.dragging) { + return + } + const data = this.createDraggableData(coreData) + this.props.onDrag && this.props.onDrag(ev, data) + if (data.x !== this.state.x || data.y !== this.state.y) { + this.setState({ x: data.x, y: data.y }) + } + } + onDragStop(ev: MouseEvent, coreData: DraggableData) { + if (!this.state.dragging) { + return + } + this.props.onStop && this.props.onStop(ev, this.createDraggableData(coreData)) + this.setState({ dragging: false }) + } + override render(): React.ReactElement { + const { x, y, dragging } = this.state + const { children } = this.props + const style: React.CSSProperties = { + position: 'fixed', + backgroundColor: 'black', + border: '1px solid rgb(70 70 70)', + cursor: dragging ? 'grabbing' : 'grab', + padding: 6, + left: 0, + top: 0, + transform: `translate(${x}px,${y}px)`, + } + return ( + React.createElement( + DraggableCore, + { + ...this.props, + onStart: this.onDragStart, + onDrag: this.onDrag, + onStop: this.onDragStop, + }, + React.cloneElement(React.Children.only(children), { + style: { ...children.props.style, ...style }, + }), + ) + ) + } +} + +/** + * Button + */ + +type ButtonProps = React.PropsWithChildren< + React.DetailedHTMLProps, HTMLButtonElement> +> + +export class Button extends React.PureComponent { + override render(): React.ReactNode { + const { children, className, ...props } = this.props + const classes: string[] = [className ?? ButtonClass.Primary] + if (props.disabled) { + classes.push(ButtonClass.Disabled) + } + return ( + + ) + } +} diff --git a/src/examples/hacknet-upgrade.tsx b/src/examples/hacknet-upgrade.tsx new file mode 100644 index 0000000..f662d08 --- /dev/null +++ b/src/examples/hacknet-upgrade.tsx @@ -0,0 +1,115 @@ +import { baseFlagsConfig, IBaseFlagsConfig } from "./common/autocomplete" +import { AppPropsWith, asyncRun, Button, ButtonClass } from "./common/react-mini-lib" + +type Props = AppPropsWith +type State = { + moneyThresh: number, +} + +class App extends React.PureComponent { + constructor(props: Props) { + super(props) + this.state = { + moneyThresh: 0, + } + } + close() { + this.props.onClose() + } + single(): number | undefined { + const { ns } = this.props + const money = ns.getPlayer().money - 1e6 + let method: undefined | Function + let price = Infinity + let cost = ns.hacknet.getPurchaseNodeCost() + if (money >= cost) { + price = cost + method = ns.hacknet.purchaseNode.bind(ns.hacknet) + } + for (const hs of _.range(ns.hacknet.numNodes())) { + cost = ns.hacknet.getLevelUpgradeCost(hs, 1) + if (money >= cost && cost < price) { + price = cost + method = ns.hacknet.upgradeLevel.bind(ns.hacknet, hs, 1) + } + cost = ns.hacknet.getCoreUpgradeCost(hs, 1) + if (money >= cost && cost < price) { + price = cost + method = ns.hacknet.upgradeCore.bind(ns.hacknet, hs, 1) + } + cost = ns.hacknet.getCacheUpgradeCost(hs, 1) + if (money >= cost && cost < price) { + price = cost + method = ns.hacknet.upgradeCache.bind(ns.hacknet, hs, 1) + } + cost = ns.hacknet.getRamUpgradeCost(hs, 1) + if (money >= cost && cost < price) { + price = cost + method = ns.hacknet.upgradeRam.bind(ns.hacknet, hs, 1) + } + } + if (method !== undefined) { + method() + return price + } + return + } + atLeast() { + let spent = 0 + while (spent < this.state.moneyThresh) { + const val = this.single() + if (val !== undefined) { + spent += val + } else { + break + } + } + } + override render(): React.ReactNode { + const { ns, onClose, children, data, ...props } = this.props + return ( +

+
+ + Upgrading Hacknodes +
+
+ Upgrade: + +
+
+ Spend $ {ns.nFormat(this.state.moneyThresh, '0,0.0a')}: + this.setState({ moneyThresh: eval(ev.target.value) })} /> + +
+
+ ) + } +} + +export enum Flag { + Never = '', + NoDaemon = 'no-daemon', + Limit = 'limit' +} + +const flagsConfig: AutocompleteConfig = [ + ...baseFlagsConfig, + [Flag.NoDaemon, false], + [Flag.Limit, 0], +] + +interface IFlagsConfig extends IBaseFlagsConfig { + [Flag.Never]: void, + [Flag.NoDaemon]: boolean, + [Flag.Limit]: number, +} + +export function autocomplete(data: AutocompleteData, args: string[]) { + const flags = data.flags(flagsConfig) + return [] +} + +export async function main(ns: NS) { + return asyncRun(ns, App, flagsConfig) +}