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/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) +} 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') +} 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",