├── .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 |
--------------------------------------------------------------------------------