├── .node-version ├── .gitignore ├── tsconfig.test.json ├── .vscode └── settings.json ├── src ├── index.ts ├── test │ ├── setup.ts │ └── events.test.ts ├── types.ts └── adapter.ts ├── tsconfig.buildLua.json ├── tsconfig.build.json ├── jest.config.js ├── .github └── workflows │ └── tests.yml ├── tsconfig.json ├── package.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 18.6.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | dist 4 | coverage 5 | __lua__ -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { "allowJs": true } 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "/mnt/c/Users/verit/projects/w3ts-jsx/node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "./types"; 2 | export * from "./adapter"; 3 | export * from "./types"; 4 | export * from "basic-pragma"; 5 | -------------------------------------------------------------------------------- /tsconfig.buildLua.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "tstl": { 4 | "buildMode": "library", 5 | "noImplicitSelf": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { "types": ["war3-types-strict/1.32.10"] }, 4 | "include": ["src"], 5 | "exclude": ["**/*.test.ts", "**/*.test.tsx", "**/test/**"] 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | globals: { 'ts-jest': { tsconfig: 'tsconfig.test.json' } }, 4 | preset: "ts-jest/presets/js-with-ts", 5 | setupFilesAfterEnv: ["./src/test/setup.ts"], 6 | testEnvironment: "jsdom", 7 | transformIgnorePatterns: [], 8 | }; 9 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | - push 4 | - workflow_dispatch 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: '18.x' 13 | - run: npm ci 14 | - run: npm run build 15 | - run: npm test 16 | - uses: denoland/setup-deno@v1 17 | - run: npm run test-lint -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "jsx": "react", 6 | "lib": ["ESNext"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "outDir": "dist/", 10 | "strict": true, 11 | "target": "ESNext", 12 | "types": [ 13 | "jest", 14 | "node", 15 | "war3-types-strict/1.32.10" 16 | ] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | // mdx-m3-viewer preps a canvas even though we don't use it 2 | import "jest-environment-jsdom"; 3 | HTMLCanvasElement.prototype.getContext = jest.fn(); 4 | 5 | // TextDecoder used with Fengari interface 6 | import { TextDecoder, TextEncoder } from "util"; 7 | Object.assign(globalThis, { TextDecoder, TextEncoder }); 8 | 9 | // WC3 APIs 10 | import * as w3api from "w3api/dist/api"; 11 | Object.assign(globalThis, w3api); 12 | 13 | // w3ts APIs 14 | import * as w3ts from "w3api/dist/w3ts"; 15 | Object.assign(globalThis, w3ts); 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "w3ts-jsx", 3 | "version": "3.0.1", 4 | "description": "JSX for Warcraft III maps", 5 | "author": "verit", 6 | "license": "ISC", 7 | "main": "dist/index", 8 | "types": "dist/index", 9 | "scripts": { 10 | "build-js": "tsc --project tsconfig.build.json", 11 | "build-lua": "tstl --project tsconfig.buildLua.json", 12 | "clean": "rm -rf dist", 13 | "build": "npm run clean && npm run build-js & npm run build-lua", 14 | "prepublishOnly": "npm run test-lint & npm run build & npm test", 15 | "test-debug": "node --inspect-brk node_modules/.bin/jest --runInBand", 16 | "test-lint": "deno lint . --ignore=node_modules,dist,src/test/jsIntegration/dist", 17 | "test": "jest" 18 | }, 19 | "peerDependencies": { 20 | "basic-pragma": "^4.4.3" 21 | }, 22 | "devDependencies": { 23 | "@types/jest": "^28.1.7", 24 | "basic-pragma": "^4.4.3", 25 | "jest": "^28.1.3", 26 | "jest-environment-jsdom": "^29.0.3", 27 | "ts-jest": "^28.0.8", 28 | "typescript-to-lua": "^1.9.0", 29 | "w3api": "^2.4.1", 30 | "war3-types-strict": "github:Z-Machine/war3-types-strict" 31 | }, 32 | "files": [ 33 | "dist" 34 | ], 35 | "repository": { 36 | "type": "git", 37 | "url": "git+https://github.com/voces/w3ts-jsx.git" 38 | }, 39 | "bugs": { 40 | "url": "https://github.com/voces/w3ts-jsx/issues" 41 | }, 42 | "homepage": "https://github.com/voces/w3ts-jsx#readme" 43 | } 44 | -------------------------------------------------------------------------------- /src/test/events.test.ts: -------------------------------------------------------------------------------- 1 | import { adapter } from "../adapter"; 2 | import { initUI } from "w3api/dist/ui/init"; 3 | 4 | const triggerAddConditionMock = jest.fn(); 5 | globalThis.TriggerAddCondition = triggerAddConditionMock; 6 | 7 | const blzTriggerRegisterFrameEventMock = jest.fn(); 8 | globalThis.BlzTriggerRegisterFrameEvent = blzTriggerRegisterFrameEventMock; 9 | 10 | const triggerClearConditionsMock = jest.fn(); 11 | globalThis.TriggerClearConditions = triggerClearConditionsMock; 12 | 13 | beforeEach(() => { 14 | triggerAddConditionMock.mockClear(); 15 | blzTriggerRegisterFrameEventMock.mockClear(); 16 | triggerClearConditionsMock.mockClear(); 17 | initUI(); 18 | }); 19 | 20 | it("clears conditions when changing callback", () => { 21 | let props = {}; 22 | let nextProps = {}; 23 | const frame = adapter.createFrame( 24 | "container", 25 | BlzCreateFrame("foo", undefined as unknown as framehandle, 0, 0), 26 | props, 27 | ); 28 | 29 | expect(triggerAddConditionMock).toHaveBeenCalledTimes(0); 30 | 31 | nextProps = { onClick: jest.fn() }; 32 | adapter.updateFrameProperties(frame, props, nextProps); 33 | props = nextProps; 34 | 35 | expect(blzTriggerRegisterFrameEventMock).toHaveBeenCalledTimes(1); 36 | expect(triggerClearConditionsMock).toHaveBeenCalledTimes(0); 37 | expect(triggerAddConditionMock).toHaveBeenCalledTimes(1); 38 | 39 | nextProps = { onClick: jest.fn() }; 40 | adapter.updateFrameProperties(frame, props, nextProps); 41 | props = nextProps; 42 | 43 | expect(blzTriggerRegisterFrameEventMock).toHaveBeenCalledTimes(1); 44 | expect(triggerClearConditionsMock).toHaveBeenCalledTimes(1); 45 | expect(triggerAddConditionMock).toHaveBeenCalledTimes(2); 46 | }); 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # w3ts-jsx 2 | 3 | Add JSX to your WC3 maps! 4 | 5 | ## Features 6 | 7 | | Feature | Status | 8 | | --------------------------------------------------------------------------------------------- | ----------------------- | 9 | | [Box model](https://developer.mozilla.org/en-US/docs/Learn/CSS/Building_blocks/The_box_model) | ❌ | 10 | | [Base frame types](https://github.com/voces/w3ts-jsx/issues/1) | ✔️ | 11 | | Lifecycle methods | ❌ (possible with hooks) | 12 | | JSX | ✔️ | 13 | | Class components | ✔️ | 14 | | Functional components | ✔️ | 15 | | [Hooks](https://github.com/voces/basic-pragma/issues/1) | ✔️ | 16 | | [Fragments](https://github.com/voces/w3ts-jsx/issues/2) | ✔️ | 17 | 18 | ## Usage 19 | 20 | 1. Install the dependency 21 | 22 | ``` 23 | npm install -S w3ts-jsx 24 | ``` 25 | 26 | 2. Configure `tsconfig.json` 27 | 28 | ```ts 29 | { 30 | "compilerOptions": { 31 | "jsx": "react", 32 | "jsxFactory": "createElement", 33 | "jsxFragmentFactory": "Fragment" 34 | } 35 | } 36 | ``` 37 | 38 | 3. Implement a JSX component 39 | 40 | ```tsx 41 | import { createElement, useEffect, useState } from "w3ts-jsx"; 42 | import { Timer } from "@voces/w3ts"; 43 | 44 | export const App = () => { 45 | const [count, setCount] = useState(0); 46 | 47 | useEffect(() => { 48 | const timer = new Timer(); 49 | timer.start(1, true, () => setCount((c) => c + 1)); 50 | 51 | return () => timer.destroy(); 52 | }, []); 53 | 54 | return ( 55 | 67 | ); 68 | }; 69 | ``` 70 | 71 | 4. Render it 72 | 73 | ```tsx 74 | import { adapter, createElement, render, setAdapter } from "w3ts-jsx"; 75 | import { App } from "./App"; 76 | 77 | setAdapter(adapter); 78 | 79 | render(, BlzGetOriginFrame(ORIGIN_FRAME_GAME_UI, 0)); 80 | ``` 81 | 82 | ### Examples 83 | 84 | - [w3ts-jsx-example](https://github.com/voces/w3ts-jsx-example). 85 | - [duels](https://github.com/voces/duels). 86 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Children, VNode } from "basic-pragma"; 2 | 3 | export type RelativeFrame = 4 | | framehandle 5 | | "previous" 6 | | "parent" 7 | | "children" 8 | | "children-reverse"; 9 | 10 | export type Pos = 11 | | { 12 | point: framepointtype; 13 | relative: RelativeFrame; 14 | relativePoint: framepointtype; 15 | x?: number; 16 | y?: number; 17 | } 18 | | "parent" 19 | | "clear"; 20 | 21 | export type AbsPos = 22 | | { 23 | point: framepointtype; 24 | x?: number; 25 | y?: number; 26 | } 27 | | "clear"; 28 | 29 | type Handler = (() => void) | null; 30 | 31 | // Props shared by all frame types 32 | type CommonFrameProps = { 33 | // immutable props 34 | name?: string; 35 | priority?: number; 36 | isSimple?: boolean; 37 | typeName?: string | null; 38 | inherits?: string; 39 | context?: number; 40 | key?: string | number | null; 41 | // mutable props 42 | /** 0-255 */ 43 | alpha?: number; 44 | enabled?: boolean; 45 | font?: { fileName?: string; height?: number; flags?: number }; 46 | level?: number; 47 | maxLength?: number; 48 | minMaxValue?: { min?: number; max?: number }; 49 | scale?: number; 50 | text?: string; 51 | textAlignment?: { vert: textaligntype; horz: textaligntype }; 52 | textColor?: number; 53 | texture?: { texFile?: string; flag?: number; blend?: boolean } | string; 54 | value?: number; 55 | vertexColor?: number; 56 | visible?: boolean; 57 | size?: { width?: number; height?: number } | number; 58 | position?: Pos | Pos[] | null; 59 | absPosition?: AbsPos | AbsPos[] | null; 60 | ref?: { current: framehandle | null } | null; 61 | children?: Children; 62 | }; 63 | 64 | // Props shared by all simple frames 65 | type SimpleFrameProps = CommonFrameProps; 66 | 67 | // Props shared by all frames that are not simple frames 68 | type ComplexFrameProps = CommonFrameProps & { 69 | // deno-lint-ignore no-explicit-any 70 | tooltip?: VNode | null; 71 | }; 72 | 73 | export type FrameProps = ComplexFrameProps & { 74 | model?: { modelFile?: string; cameraIndex?: number } | string; 75 | spriteAnimate?: { primaryProp: number; flags: number }; 76 | stepSize?: number; 77 | onClick?: Handler; 78 | onMouseEnter?: Handler; 79 | onMouseLeave?: Handler; 80 | onMouseUp?: Handler; 81 | onMouseDown?: Handler; 82 | onMouseWheel?: Handler; 83 | onCheckboxChecked?: Handler; 84 | onCheckboxUnchecked?: Handler; 85 | onEditboxTextChanged?: Handler; 86 | onPopupmenuItemChanged?: Handler; 87 | onDoubleClick?: Handler; 88 | onSpriteAnimUpdate?: Handler; 89 | onSliderChanged?: Handler; 90 | onDialogCancel?: Handler; 91 | onDialogAccept?: Handler; 92 | onEditboxEnter?: Handler; 93 | }; 94 | 95 | export type BackdropProps = ComplexFrameProps; 96 | 97 | export type ButtonProps = ComplexFrameProps & { 98 | onClick?: Handler; 99 | onMouseEnter?: Handler; 100 | onMouseLeave?: Handler; 101 | onMouseUp?: Handler; 102 | onMouseDown?: Handler; 103 | onMouseWheel?: Handler; 104 | onDoubleClick?: Handler; 105 | }; 106 | 107 | export type ChatDisplayProps = ComplexFrameProps & { 108 | onClick?: Handler; 109 | onMouseEnter?: Handler; 110 | onMouseLeave?: Handler; 111 | onMouseUp?: Handler; 112 | onMouseDown?: Handler; 113 | onMouseWheel?: Handler; 114 | onDoubleClick?: Handler; 115 | }; 116 | 117 | export type CheckboxProps = ComplexFrameProps & { 118 | onClick?: Handler; 119 | onMouseEnter?: Handler; 120 | onMouseLeave?: Handler; 121 | onMouseUp?: Handler; 122 | onMouseDown?: Handler; 123 | onMouseWheel?: Handler; 124 | onCheckboxChecked?: Handler; 125 | onCheckboxUnchecked?: Handler; 126 | onDoubleClick?: Handler; 127 | }; 128 | 129 | export type ControlProps = ComplexFrameProps & { 130 | onMouseEnter?: Handler; 131 | onMouseLeave?: Handler; 132 | onMouseUp?: Handler; 133 | onMouseDown?: Handler; 134 | }; 135 | 136 | export type DialogProps = ComplexFrameProps & { 137 | onDialogCancel?: Handler; 138 | onDialogAccept?: Handler; 139 | }; 140 | 141 | export type EditBoxProps = ComplexFrameProps & { 142 | onClick?: Handler; 143 | onMouseEnter?: Handler; 144 | onMouseLeave?: Handler; 145 | onMouseUp?: Handler; 146 | onMouseDown?: Handler; 147 | onMouseWheel?: Handler; 148 | onEditboxTextChanged?: Handler; 149 | onDoubleClick?: Handler; 150 | onEditboxEnter?: Handler; 151 | }; 152 | 153 | export type ContainerProps = ComplexFrameProps; 154 | 155 | export type GlueButtonProps = ComplexFrameProps & { 156 | onClick?: Handler; 157 | onMouseEnter?: Handler; 158 | onMouseLeave?: Handler; 159 | onMouseUp?: Handler; 160 | onMouseDown?: Handler; 161 | onMouseWheel?: Handler; 162 | }; 163 | 164 | export type GlueCheckboxProps = ComplexFrameProps & { 165 | onClick?: Handler; 166 | onMouseEnter?: Handler; 167 | onMouseLeave?: Handler; 168 | onMouseUp?: Handler; 169 | onMouseDown?: Handler; 170 | onMouseWheel?: Handler; 171 | onCheckboxChecked?: Handler; 172 | onCheckboxUnchecked?: Handler; 173 | onDoubleClick?: Handler; 174 | }; 175 | 176 | export type GlueEditBoxProps = ComplexFrameProps & { 177 | onClick?: Handler; 178 | onMouseEnter?: Handler; 179 | onMouseLeave?: Handler; 180 | onMouseUp?: Handler; 181 | onMouseDown?: Handler; 182 | onMouseWheel?: Handler; 183 | onEditboxTextChanged?: Handler; 184 | onDoubleClick?: Handler; 185 | onEditboxEnter?: Handler; 186 | }; 187 | 188 | export type GluePopupMenuProps = ComplexFrameProps & { 189 | onClick?: Handler; 190 | onMouseEnter?: Handler; 191 | onMouseLeave?: Handler; 192 | onMouseWheel?: Handler; 193 | onPopupmenuItemChanged?: Handler; 194 | onDoubleClick?: Handler; 195 | }; 196 | 197 | export type GlueTextButtonProps = ComplexFrameProps & { 198 | onClick?: Handler; 199 | onMouseEnter?: Handler; 200 | onMouseLeave?: Handler; 201 | onMouseUp?: Handler; 202 | onMouseDown?: Handler; 203 | onMouseWheel?: Handler; 204 | onDoubleClick?: Handler; 205 | }; 206 | 207 | export type HighlightProps = ComplexFrameProps; 208 | 209 | export type ListBoxProps = ComplexFrameProps & { 210 | onMouseEnter?: Handler; 211 | onMouseLeave?: Handler; 212 | onMouseUp?: Handler; 213 | onMouseDown?: Handler; 214 | onMouseWheel?: Handler; 215 | }; 216 | 217 | export type MenuProps = ComplexFrameProps & { 218 | onMouseEnter?: Handler; 219 | onMouseLeave?: Handler; 220 | onMouseUp?: Handler; 221 | onMouseDown?: Handler; 222 | onMouseWheel?: Handler; 223 | }; 224 | 225 | export type ModelProps = ComplexFrameProps & { 226 | model?: { modelFile?: string; cameraIndex?: number } | string; 227 | onMouseEnter?: Handler; 228 | onMouseLeave?: Handler; 229 | onMouseUp?: Handler; 230 | onMouseDown?: Handler; 231 | onMouseWheel?: Handler; 232 | }; 233 | 234 | export type PopupMenuProps = ComplexFrameProps & { 235 | onClick?: Handler; 236 | onMouseEnter?: Handler; 237 | onMouseLeave?: Handler; 238 | onMouseWheel?: Handler; 239 | onPopupmenuItemChanged?: Handler; 240 | onDoubleClick?: Handler; 241 | }; 242 | 243 | export type ScrollbarProps = ComplexFrameProps & { 244 | stepSize?: number; 245 | onMouseEnter?: Handler; 246 | onMouseLeave?: Handler; 247 | onMouseUp?: Handler; 248 | onMouseDown?: Handler; 249 | onMouseWheel?: Handler; 250 | onSliderChanged?: Handler; 251 | }; 252 | 253 | export type SimpleButtonProps = SimpleFrameProps & { 254 | // deno-lint-ignore no-explicit-any 255 | tooltip?: VNode | null; 256 | onClick?: Handler; 257 | }; 258 | 259 | export type SimpleCheckboxProps = SimpleFrameProps; 260 | 261 | export type SimpleContainerProps = SimpleFrameProps; 262 | 263 | export type SimpleStatusBarProps = SimpleFrameProps; 264 | 265 | export type SlashChatboxProps = ComplexFrameProps & { 266 | onClick?: Handler; 267 | onMouseEnter?: Handler; 268 | onMouseLeave?: Handler; 269 | onMouseUp?: Handler; 270 | onMouseDown?: Handler; 271 | onMouseWheel?: Handler; 272 | onEditboxTextChanged?: Handler; 273 | onDoubleClick?: Handler; 274 | onEditboxEnter?: Handler; 275 | }; 276 | 277 | export type SliderProps = ComplexFrameProps & { 278 | stepSize?: number; 279 | onClick?: Handler; 280 | onMouseEnter?: Handler; 281 | onMouseLeave?: Handler; 282 | onMouseUp?: Handler; 283 | onMouseDown?: Handler; 284 | onMouseWheel?: Handler; 285 | onSliderChanged?: Handler; 286 | onDoubleClick?: Handler; 287 | }; 288 | 289 | export type SpriteProps = ComplexFrameProps & { 290 | spriteAnimate?: { primaryProp: number; flags: number }; 291 | onSpriteAnimUpdate?: Handler; 292 | }; 293 | 294 | export type TextProps = ComplexFrameProps & { 295 | onClick?: Handler; 296 | onMouseEnter?: Handler; 297 | onMouseLeave?: Handler; 298 | onMouseUp?: Handler; 299 | onMouseDown?: Handler; 300 | onMouseWheel?: Handler; 301 | onDoubleClick?: Handler; 302 | }; 303 | 304 | export type TextAreaProps = ComplexFrameProps & { 305 | onMouseEnter?: Handler; 306 | onMouseLeave?: Handler; 307 | onMouseUp?: Handler; 308 | onMouseDown?: Handler; 309 | onMouseWheel?: Handler; 310 | }; 311 | 312 | export type TextButtonProps = ComplexFrameProps & { 313 | onClick?: Handler; 314 | onMouseEnter?: Handler; 315 | onMouseLeave?: Handler; 316 | onMouseUp?: Handler; 317 | onMouseDown?: Handler; 318 | onMouseWheel?: Handler; 319 | onDoubleClick?: Handler; 320 | }; 321 | 322 | export type TimerTextProps = ComplexFrameProps & { 323 | onClick?: Handler; 324 | onMouseEnter?: Handler; 325 | onMouseLeave?: Handler; 326 | onMouseUp?: Handler; 327 | onMouseDown?: Handler; 328 | onMouseWheel?: Handler; 329 | onDoubleClick?: Handler; 330 | }; 331 | 332 | export type StatusBarProps = ComplexFrameProps & { 333 | model: { modelFile?: string; cameraIndex?: number } | string; 334 | }; 335 | 336 | declare global { 337 | namespace JSX { 338 | interface IntrinsicElements { 339 | frame: FrameProps; 340 | "simple-frame": FrameProps; 341 | backdrop: BackdropProps; 342 | button: ButtonProps; 343 | chatdisplay: ChatDisplayProps; 344 | checkbox: CheckboxProps; 345 | control: ControlProps; 346 | dialog: DialogProps; 347 | editbox: EditBoxProps; 348 | container: ContainerProps; 349 | gluebutton: GlueButtonProps; 350 | gluecheckbox: GlueCheckboxProps; 351 | glueeditbox: GlueEditBoxProps; 352 | gluepopupmenu: GluePopupMenuProps; 353 | gluetextbutton: GlueTextButtonProps; 354 | heightlight: HighlightProps; 355 | listbox: ListBoxProps; 356 | menu: MenuProps; 357 | model: ModelProps; 358 | popupmenu: PopupMenuProps; 359 | scrollbar: ScrollbarProps; 360 | "simple-button": SimpleButtonProps; 361 | "simple-checkbox": SimpleCheckboxProps; 362 | "simple-container": SimpleContainerProps; 363 | "simple-statusbar": SimpleStatusBarProps; 364 | slashchatbox: SlashChatboxProps; 365 | slider: SliderProps; 366 | sprite: SpriteProps; 367 | text: TextProps; 368 | textarea: TextAreaProps; 369 | textbutton: TextButtonProps; 370 | timertext: TimerTextProps; 371 | statusbar: StatusBarProps; 372 | } 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /src/adapter.ts: -------------------------------------------------------------------------------- 1 | import { flushUpdates, render } from "basic-pragma"; 2 | import type { Adapter } from "basic-pragma"; 3 | import type { AbsPos, FrameProps, Pos, RelativeFrame } from "./types"; 4 | 5 | // https://wc3modding.info/pages/jass-documentation-database/class/functions/file/common.j/ 6 | // https://discordapp.com/channels/178569180625240064/311662737015046144/764384452867784704 7 | 8 | declare interface Console { 9 | // deno-lint-ignore no-explicit-any 10 | log: (...args: any[]) => void; 11 | // deno-lint-ignore no-explicit-any 12 | error: (...args: any[]) => void; 13 | } 14 | 15 | // deno-lint-ignore no-var 16 | declare var console: Console; 17 | 18 | const frameDefaults = { 19 | // immutable props 20 | name: "AnonymousFrame", 21 | priority: 0, 22 | isSimple: true, 23 | typeName: null, 24 | inherits: "", 25 | context: 0, 26 | key: null, 27 | // mutable props 28 | alpha: 255, 29 | enabled: true, 30 | font: { fileName: "", height: 16, flags: 0 }, 31 | level: 0, 32 | maxLength: 9999, 33 | minMaxValue: { min: -999999999, max: 999999999 }, 34 | model: { modelFile: "", cameraIndex: 0 }, 35 | scale: 1, 36 | spriteAnimate: { primaryProp: 0, flags: 0 }, 37 | stepSize: 0, 38 | text: "", 39 | textAlignment: { vert: TEXT_JUSTIFY_TOP, horz: TEXT_JUSTIFY_LEFT }, 40 | textColor: 0xffffff, 41 | texture: { texFile: "", flag: 0, blend: true }, 42 | tooltip: null, 43 | value: 0, 44 | vertexColor: 0xffffff, 45 | visible: true, 46 | position: null, 47 | absPosition: null, 48 | size: { width: 0, height: 0 }, 49 | children: null, 50 | ref: null, 51 | // events 52 | onClick: null, 53 | onMouseEnter: null, 54 | onMouseLeave: null, 55 | onMouseUp: null, 56 | onMouseDown: null, 57 | onMouseWheel: null, 58 | onCheckboxChecked: null, 59 | onCheckboxUnchecked: null, 60 | onEditboxTextChanged: null, 61 | onPopupmenuItemChanged: null, 62 | onDoubleClick: null, 63 | onSpriteAnimUpdate: null, 64 | onSliderChanged: null, 65 | onDialogCancel: null, 66 | onDialogAccept: null, 67 | onEditboxEnter: null, 68 | } as const; 69 | 70 | // Just a type assertion; we want const above to handle polymorphic types 71 | frameDefaults as Required & { children: null }; 72 | 73 | const absurd = (value: never) => { 74 | throw `Got ${value} when expected nothing`; 75 | }; 76 | 77 | const triggerMap = new WeakMap<() => void, trigger>(); 78 | 79 | const setEventProp = ( 80 | frame: framehandle, 81 | // This typing is wrong, should be prop: K, value?: FrameProps[K] 82 | event: frameeventtype, 83 | val?: () => void, 84 | oldValue?: () => void, 85 | ) => { 86 | // Get the existing trigger 87 | let t = triggerMap.get(oldValue!); 88 | 89 | // Destroy it if there's no mouse event 90 | if (val == null) { 91 | if (t) { 92 | DestroyTrigger(t); 93 | triggerMap.delete(oldValue!); 94 | } 95 | 96 | return; 97 | } 98 | 99 | // Create the trigger if it doesn't exist 100 | if (t == null) { 101 | t = CreateTrigger(); 102 | BlzTriggerRegisterFrameEvent(t, frame, event); 103 | } // Otherwise clear old conditions 104 | else TriggerClearConditions(t); 105 | 106 | TriggerAddCondition( 107 | t, 108 | Condition(() => { 109 | // Clear focus 110 | if (event === FRAMEEVENT_CONTROL_CLICK) { 111 | BlzFrameSetEnable(frame, false); 112 | BlzFrameSetEnable(frame, true); 113 | } 114 | val(); 115 | return false; 116 | })!, 117 | ); 118 | triggerMap.set(val, t); 119 | }; 120 | 121 | const firstChildRelativePoints = [ 122 | FRAMEPOINT_TOPLEFT, 123 | FRAMEPOINT_TOP, 124 | FRAMEPOINT_LEFT, 125 | ]; 126 | 127 | const lastChildRelativePoints = [ 128 | FRAMEPOINT_RIGHT, 129 | FRAMEPOINT_BOTTOM, 130 | FRAMEPOINT_BOTTOMRIGHT, 131 | ]; 132 | 133 | const resolveRelative = ( 134 | frame: framehandle, 135 | relative: RelativeFrame, 136 | relativePoint: framepointtype, 137 | ) => { 138 | if (typeof relative !== "string") return relative; 139 | 140 | switch (relative) { 141 | case "parent": 142 | return BlzFrameGetParent(frame); 143 | case "previous": { 144 | const parent = BlzFrameGetParent(frame)!; 145 | const children = BlzFrameGetChildrenCount(parent); 146 | let index = -1; 147 | for (let i = 0; i < children; i++) { 148 | if (BlzFrameGetChild(parent, i) === frame) { 149 | index = i; 150 | break; 151 | } 152 | } 153 | if (index > 0) return BlzFrameGetChild(parent, index - 1); 154 | return null; 155 | } 156 | // TODO: this behavior is error prone as it requires a re-render since children might not exist yet 157 | case "children": 158 | if (firstChildRelativePoints.includes(relativePoint)) { 159 | return BlzFrameGetChild(frame, 0); 160 | } 161 | if (lastChildRelativePoints.includes(relativePoint)) { 162 | return BlzFrameGetChild( 163 | frame, 164 | BlzFrameGetChildrenCount(frame) - 1, 165 | ); 166 | } 167 | throw `When using relative=children, expected relativePoint to be in ${firstChildRelativePoints} or ${lastChildRelativePoints}`; 168 | // TODO: this behavior is error prone as it requires a re-render since children might not exist yet 169 | case "children-reverse": 170 | if (lastChildRelativePoints.includes(relativePoint)) { 171 | return BlzFrameGetChild(frame, 0); 172 | } 173 | if (firstChildRelativePoints.includes(relativePoint)) { 174 | return BlzFrameGetChild( 175 | frame, 176 | BlzFrameGetChildrenCount(frame) - 1, 177 | ); 178 | } 179 | throw `When using relative=children, expected relativePoint to be in ${firstChildRelativePoints} or ${lastChildRelativePoints}`; 180 | default: 181 | absurd(relative); 182 | } 183 | }; 184 | 185 | const previousToParentPoint = (relative: framepointtype) => { 186 | switch (relative) { 187 | // Span 188 | case FRAMEPOINT_RIGHT: 189 | return FRAMEPOINT_LEFT; 190 | // Div 191 | case FRAMEPOINT_BOTTOM: 192 | return FRAMEPOINT_TOP; 193 | 194 | case FRAMEPOINT_BOTTOMLEFT: 195 | return FRAMEPOINT_TOPLEFT; 196 | case FRAMEPOINT_BOTTOMRIGHT: 197 | return FRAMEPOINT_TOPRIGHT; 198 | } 199 | }; 200 | 201 | const tooltipMap = new WeakMap(); 202 | 203 | let scale = 1600 * 1.25; 204 | /** 205 | * Sets the UI scale for pixel measurements. Defaults to 1600. 206 | */ 207 | export const setPixelScale = (newScale: number): void => { 208 | scale = newScale * 1.25; 209 | }; 210 | 211 | /** 212 | * Allows usage of Blizzard sizes (where 0.8 fills the 5:4 box width, 0.6 the 213 | * 5:4 box height) and pixels (where 1600 is the full width and 1200 is the 214 | * full height) 215 | */ 216 | const smartSize = (size: number) => size < 1 && size > -1 ? size : size / scale; 217 | 218 | const setProp = ( 219 | frame: framehandle, 220 | // This typing is wrong, should be prop: K, value?: FrameProps[K] 221 | prop: keyof FrameProps, 222 | value?: FrameProps[keyof FrameProps], 223 | oldValue?: FrameProps[keyof FrameProps], 224 | ) => { 225 | // deno-lint-ignore no-explicit-any 226 | const val = value ?? (frameDefaults[prop] as any); 227 | // deno-lint-ignore no-explicit-any 228 | const _oldValue = oldValue as any; 229 | switch (prop) { 230 | case "text": { 231 | BlzFrameSetText(frame, val); 232 | break; 233 | } 234 | case "maxLength": { 235 | BlzFrameSetTextSizeLimit(frame, val); 236 | break; 237 | } 238 | case "textColor": { 239 | BlzFrameSetTextColor(frame, val); 240 | break; 241 | } 242 | case "texture": { 243 | const val2 = typeof val === "string" ? { texFile: val } : val; 244 | BlzFrameSetTexture( 245 | frame, 246 | val2.texFile ?? frameDefaults.texture.texFile, 247 | val2.flag ?? frameDefaults.texture.flag, 248 | val2.blend ?? frameDefaults.texture.blend, 249 | ); 250 | break; 251 | } 252 | case "model": { 253 | const modelVal = typeof val === "string" 254 | ? { 255 | modelFile: val, 256 | cameraIndex: 0, 257 | } 258 | : val; 259 | BlzFrameSetModel( 260 | frame, 261 | modelVal.modelFile ?? frameDefaults.model.modelFile, 262 | modelVal.cameraIndex ?? frameDefaults.model.cameraIndex, 263 | ); 264 | break; 265 | } 266 | case "alpha": { 267 | BlzFrameSetAlpha(frame, val); 268 | break; 269 | } 270 | case "level": { 271 | BlzFrameSetLevel(frame, val); 272 | break; 273 | } 274 | case "visible": { 275 | BlzFrameSetVisible(frame, val); 276 | break; 277 | } 278 | case "enabled": { 279 | BlzFrameSetEnable(frame, val); 280 | break; 281 | } 282 | case "vertexColor": { 283 | BlzFrameSetVertexColor(frame, val); 284 | break; 285 | } 286 | case "value": { 287 | BlzFrameSetValue(frame, val); 288 | break; 289 | } 290 | case "size": { 291 | const size = typeof val === "number" ? { width: val, height: val } : val; 292 | BlzFrameSetSize( 293 | frame, 294 | smartSize(size.width ?? frameDefaults.size.width), 295 | smartSize(size.height ?? frameDefaults.size.height), 296 | ); 297 | break; 298 | } 299 | case "stepSize": { 300 | BlzFrameSetStepSize(frame, val); 301 | break; 302 | } 303 | case "tooltip": { 304 | const existingTooltip = tooltipMap.get(frame); 305 | let tooltip; 306 | if (existingTooltip) tooltip = existingTooltip; 307 | else { 308 | tooltip = adapter.createFrame( 309 | "container", 310 | BlzGetOriginFrame(ORIGIN_FRAME_GAME_UI, 0), 311 | { name: "Tooltip" }, 312 | ); 313 | tooltipMap.set(frame, tooltip); 314 | BlzFrameSetTooltip(frame, tooltip); 315 | } 316 | render(val, tooltip); 317 | break; 318 | } 319 | case "font": { 320 | BlzFrameSetFont( 321 | frame, 322 | val.fileName ?? frameDefaults.font.fileName, 323 | val.height ?? frameDefaults.font.height, 324 | val.flags ?? frameDefaults.font.flags, 325 | ); 326 | break; 327 | } 328 | case "minMaxValue": { 329 | BlzFrameSetMinMaxValue( 330 | frame, 331 | val.min ?? frameDefaults.minMaxValue.min, 332 | val.max ?? frameDefaults.minMaxValue.max, 333 | ); 334 | break; 335 | } 336 | case "scale": { 337 | BlzFrameSetScale(frame, val); 338 | break; 339 | } 340 | case "spriteAnimate": { 341 | BlzFrameSetSpriteAnimate( 342 | frame, 343 | val.primaryProp ?? frameDefaults.spriteAnimate.primaryProp, 344 | val.flags ?? frameDefaults.spriteAnimate.flags, 345 | ); 346 | break; 347 | } 348 | case "textAlignment": { 349 | BlzFrameSetTextAlignment( 350 | frame, 351 | val.vert ?? frameDefaults.textAlignment.vert, 352 | val.horz ?? frameDefaults.textAlignment.horz, 353 | ); 354 | break; 355 | } 356 | case "position": { 357 | if (val != null) { 358 | const positions: readonly Pos[] = 359 | val === "parent" || val === "clear" || "point" in val 360 | ? [val as Pos] 361 | : (val as readonly Pos[]); 362 | 363 | for (const position of positions) { 364 | if (position === "clear") BlzFrameClearAllPoints(frame); 365 | else if (position === "parent") { 366 | BlzFrameSetAllPoints(frame, BlzFrameGetParent(frame)!); 367 | } else { 368 | const relative = resolveRelative( 369 | frame, 370 | position.relative, 371 | position.relativePoint, 372 | ); 373 | if (relative) { 374 | BlzFrameSetPoint( 375 | frame, 376 | position.point, 377 | relative, 378 | position.relativePoint, 379 | smartSize(position.x ?? 0), 380 | smartSize(position.y ?? 0), 381 | ); 382 | // We used `previous` and we're the first child 383 | } else { 384 | const parentRelative = previousToParentPoint( 385 | position.relativePoint, 386 | ); 387 | if (parentRelative) { 388 | BlzFrameSetPoint( 389 | frame, 390 | position.point, 391 | BlzFrameGetParent(frame)!, 392 | parentRelative, 393 | smartSize(position.x ?? 0), 394 | smartSize(position.y ?? 0), 395 | ); 396 | } 397 | } 398 | } 399 | } 400 | } 401 | break; 402 | } 403 | case "absPosition": { 404 | if (val != null) { 405 | const positions: readonly AbsPos[] = val === "clear" || "point" in val 406 | ? [val as AbsPos] 407 | : (val as readonly AbsPos[]); 408 | 409 | for (const absPosition of positions) { 410 | if (absPosition === "clear") BlzFrameClearAllPoints(frame); 411 | else { 412 | BlzFrameSetAbsPoint( 413 | frame, 414 | absPosition.point, 415 | smartSize(absPosition.x ?? 0), 416 | smartSize(absPosition.y ?? 0), 417 | ); 418 | } 419 | } 420 | } 421 | break; 422 | } 423 | case "onClick": { 424 | setEventProp(frame, FRAMEEVENT_CONTROL_CLICK, val, _oldValue); 425 | break; 426 | } 427 | case "onMouseEnter": { 428 | setEventProp(frame, FRAMEEVENT_MOUSE_ENTER, val, _oldValue); 429 | break; 430 | } 431 | case "onMouseLeave": { 432 | setEventProp(frame, FRAMEEVENT_MOUSE_LEAVE, val, _oldValue); 433 | break; 434 | } 435 | case "onMouseUp": { 436 | setEventProp(frame, FRAMEEVENT_MOUSE_UP, val, _oldValue); 437 | break; 438 | } 439 | case "onMouseDown": { 440 | setEventProp(frame, FRAMEEVENT_MOUSE_DOWN, val, _oldValue); 441 | break; 442 | } 443 | case "onMouseWheel": { 444 | setEventProp(frame, FRAMEEVENT_MOUSE_WHEEL, val, _oldValue); 445 | break; 446 | } 447 | case "onCheckboxChecked": { 448 | setEventProp(frame, FRAMEEVENT_CHECKBOX_CHECKED, val, _oldValue); 449 | break; 450 | } 451 | case "onCheckboxUnchecked": { 452 | setEventProp(frame, FRAMEEVENT_CHECKBOX_UNCHECKED, val, _oldValue); 453 | break; 454 | } 455 | case "onEditboxTextChanged": { 456 | setEventProp( 457 | frame, 458 | FRAMEEVENT_EDITBOX_TEXT_CHANGED, 459 | val, 460 | _oldValue, 461 | ); 462 | break; 463 | } 464 | case "onPopupmenuItemChanged": { 465 | setEventProp( 466 | frame, 467 | FRAMEEVENT_POPUPMENU_ITEM_CHANGED, 468 | val, 469 | _oldValue, 470 | ); 471 | break; 472 | } 473 | case "onDoubleClick": { 474 | setEventProp(frame, FRAMEEVENT_MOUSE_DOUBLECLICK, val, _oldValue); 475 | break; 476 | } 477 | case "onSpriteAnimUpdate": { 478 | setEventProp(frame, FRAMEEVENT_SPRITE_ANIM_UPDATE, val, _oldValue); 479 | break; 480 | } 481 | case "onSliderChanged": { 482 | setEventProp( 483 | frame, 484 | FRAMEEVENT_SLIDER_VALUE_CHANGED, 485 | val, 486 | _oldValue, 487 | ); 488 | break; 489 | } 490 | case "onDialogCancel": { 491 | setEventProp(frame, FRAMEEVENT_DIALOG_CANCEL, val, _oldValue); 492 | break; 493 | } 494 | case "onDialogAccept": { 495 | setEventProp(frame, FRAMEEVENT_DIALOG_ACCEPT, val, _oldValue); 496 | break; 497 | } 498 | case "onEditboxEnter": { 499 | setEventProp(frame, FRAMEEVENT_EDITBOX_ENTER, val, _oldValue); 500 | break; 501 | } 502 | case "ref": { 503 | if (val) val.current = frame; 504 | break; 505 | } 506 | case "name": 507 | case "priority": 508 | case "isSimple": 509 | case "typeName": 510 | case "inherits": 511 | case "children": 512 | case "context": 513 | case "key": 514 | break; 515 | default: 516 | absurd(prop); 517 | } 518 | }; 519 | 520 | const typeNames = [ 521 | "backdrop", 522 | "button", 523 | "chatdisplay", 524 | "checkbox", 525 | "control", 526 | "dialog", 527 | "editbox", 528 | "gluebutton", 529 | "gluecheckbox", 530 | "glueeditbox", 531 | "gluepopupmenu", 532 | "gluetextbutton", 533 | "highlight", 534 | "listbox", 535 | "menu", 536 | "model", 537 | "popupmenu", 538 | "scrollbar", 539 | "slashchatbox", 540 | "slider", 541 | "sprite", 542 | "text", 543 | "textarea", 544 | "textbutton", 545 | "timertext", 546 | ]; 547 | 548 | const simpleTypeNames = [ 549 | "simple-button", 550 | "simple-checkbox", 551 | "simple-statusbar", 552 | ]; 553 | 554 | let updateScheduled = false; 555 | const schedulingTimer = CreateTimer(); 556 | 557 | export const adapter: Adapter< 558 | framehandle, 559 | JSX.IntrinsicElements[keyof JSX.IntrinsicElements] 560 | > = { 561 | createFrame: ( 562 | jsxType: T, 563 | parentFrame: framehandle | undefined, 564 | props: JSX.IntrinsicElements[T], 565 | ) => { 566 | if (!parentFrame) throw new Error(`expected parent frame for ${jsxType}`); 567 | 568 | const { 569 | name = frameDefaults.name, 570 | priority = frameDefaults.priority, 571 | inherits, 572 | isSimple, 573 | context = frameDefaults.context, 574 | ref, 575 | } = props; 576 | 577 | let typeName = props.typeName; 578 | 579 | if (typeName == null && typeNames.includes(jsxType)) { 580 | typeName = jsxType.toUpperCase(); 581 | } 582 | 583 | if (typeName == null && simpleTypeNames.includes(jsxType)) { 584 | typeName = jsxType.replace("-", "").toUpperCase(); 585 | } 586 | 587 | // frame is both our base component and a typeName; we expose it as 588 | // container 589 | if (typeName == null && jsxType === "container") typeName = "FRAME"; 590 | if (typeName == null && jsxType === "simple-container") { 591 | typeName = "SIMPLEFRAME"; 592 | } 593 | 594 | let frame: framehandle; 595 | 596 | if (isSimple ?? jsxType === "simple-frame") { 597 | frame = BlzCreateSimpleFrame(name, parentFrame, context)!; 598 | } else if (typeName) { 599 | frame = BlzCreateFrameByType( 600 | typeName, 601 | name, 602 | parentFrame, 603 | inherits ?? "", 604 | context, 605 | )!; 606 | } else frame = BlzCreateFrame(name, parentFrame, priority, context)!; 607 | 608 | adapter.updateFrameProperties(frame, {}, props); 609 | 610 | if (ref) ref.current = frame; 611 | 612 | return frame; 613 | }, 614 | 615 | cleanupFrame: (frame: framehandle): void => { 616 | BlzDestroyFrame(frame); 617 | const existingTooltip = tooltipMap.get(frame); 618 | if (existingTooltip) adapter.cleanupFrame(existingTooltip); 619 | }, 620 | 621 | updateFrameProperties: ( 622 | frame: framehandle, 623 | prevProps: FrameProps, 624 | nextProps: FrameProps, 625 | ) => { 626 | let prop: keyof FrameProps; 627 | 628 | // Clear removed props 629 | for (prop in prevProps) { 630 | if (!(prop in nextProps)) { 631 | try { 632 | setProp(frame, prop); 633 | } catch (err) { 634 | console.error(err); 635 | } 636 | } 637 | } 638 | 639 | // Add new props 640 | for (prop in nextProps) { 641 | if ( 642 | nextProps[prop] !== prevProps[prop] || 643 | // Hack to make relative positioning to work with variables ("children", "previous", etc) 644 | prop === "position" || prop === "absPosition" 645 | ) { 646 | try { 647 | setProp(frame, prop, nextProps[prop], prevProps[prop]); 648 | } catch (err) { 649 | console.error(err); 650 | } 651 | } 652 | } 653 | }, 654 | 655 | scheduleUpdate: () => { 656 | if (updateScheduled) return; 657 | updateScheduled = true; 658 | TimerStart(schedulingTimer, 0, false, () => { 659 | updateScheduled = false; 660 | try { 661 | flushUpdates(); 662 | } catch (err) { 663 | console.error(err); 664 | } 665 | }); 666 | }, 667 | }; 668 | --------------------------------------------------------------------------------