onejs-react 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/components.test.tsx +93 -1
- package/src/__tests__/mocks.ts +57 -3
- package/src/__tests__/painter.test.ts +103 -0
- package/src/__tests__/setup.ts +12 -1
- package/src/components.tsx +74 -3
- package/src/host-config.ts +42 -9
- package/src/index.ts +3 -0
- package/src/painter.ts +212 -0
- package/src/types.ts +0 -1
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
Image,
|
|
23
23
|
clearImageCache,
|
|
24
24
|
} from '../components';
|
|
25
|
-
import { MockVisualElement, MockTexture2D, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI, mockFileSystem } from './mocks';
|
|
25
|
+
import { MockVisualElement, MockTexture2D, MockVectorImage, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI, mockFileSystem, mockUrlAssets } from './mocks';
|
|
26
26
|
|
|
27
27
|
// Helper to extract value from style (handles both raw values and MockLength/MockColor)
|
|
28
28
|
function getStyleValue(style: unknown): unknown {
|
|
@@ -443,6 +443,98 @@ describe('components', () => {
|
|
|
443
443
|
|
|
444
444
|
expect(readSpy).toHaveBeenCalledTimes(2);
|
|
445
445
|
});
|
|
446
|
+
|
|
447
|
+
it('loads SVG synchronously via SVGUtils in the editor', async () => {
|
|
448
|
+
mockFileSystem.set("/project/App/assets/icons/star.svg", "<svg></svg>");
|
|
449
|
+
(globalThis as any).__workingDir = "/project/App";
|
|
450
|
+
|
|
451
|
+
const container = createMockContainer();
|
|
452
|
+
render(<Image src="icons/star.svg" />, container as any);
|
|
453
|
+
await flushMicrotasks();
|
|
454
|
+
|
|
455
|
+
const el = container.children[0] as any;
|
|
456
|
+
expect(el.image).toBeInstanceOf(MockVectorImage);
|
|
457
|
+
expect((el.image as MockVectorImage).svgText).toBe("<svg></svg>");
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe('Image on URL StreamingAssets platforms (Android APK, WebGL)', () => {
|
|
462
|
+
const JAR_BASE = "jar:file:///data/app/base.apk!/assets";
|
|
463
|
+
|
|
464
|
+
// Simulate an Android player: not the editor, and streamingAssetsPath
|
|
465
|
+
// is a jar: URL inside the APK that System.IO.File cannot read
|
|
466
|
+
beforeEach(() => {
|
|
467
|
+
const app = (globalThis as any).CS.UnityEngine.Application;
|
|
468
|
+
app.isEditor = false;
|
|
469
|
+
app.streamingAssetsPath = JAR_BASE;
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('loads raster images asynchronously via Network.LoadTextureFromUrl', async () => {
|
|
473
|
+
mockUrlAssets.set(`${JAR_BASE}/onejs/assets/images/logo.png`, [0x89, 0x50, 0x4e, 0x47]);
|
|
474
|
+
|
|
475
|
+
const container = createMockContainer();
|
|
476
|
+
render(<Image src="images/logo.png" />, container as any);
|
|
477
|
+
await flushMicrotasks();
|
|
478
|
+
|
|
479
|
+
const el = container.children[0] as any;
|
|
480
|
+
expect(el.image).toBeInstanceOf(MockTexture2D);
|
|
481
|
+
expect((el.image as MockTexture2D)._loaded).toBe(true);
|
|
482
|
+
expect((el.image as MockTexture2D).filterMode).toBe(1); // Bilinear
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('loads SVG asynchronously via fetch + SVGUtils', async () => {
|
|
486
|
+
mockUrlAssets.set(`${JAR_BASE}/onejs/assets/icons/spinner.svg`, "<svg>spin</svg>");
|
|
487
|
+
|
|
488
|
+
const container = createMockContainer();
|
|
489
|
+
render(<Image src="icons/spinner.svg" />, container as any);
|
|
490
|
+
await flushMicrotasks();
|
|
491
|
+
|
|
492
|
+
const el = container.children[0] as any;
|
|
493
|
+
expect(el.image).toBeInstanceOf(MockVectorImage);
|
|
494
|
+
expect((el.image as MockVectorImage).svgText).toBe("<svg>spin</svg>");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('keeps a null image and logs an error when the asset is missing', async () => {
|
|
498
|
+
const container = createMockContainer();
|
|
499
|
+
render(<Image src="images/missing.png" />, container as any);
|
|
500
|
+
await flushMicrotasks();
|
|
501
|
+
|
|
502
|
+
const el = container.children[0] as any;
|
|
503
|
+
expect(el.image).toBeNull();
|
|
504
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
505
|
+
expect.stringContaining("Image src not found: images/missing.png")
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('dedupes concurrent loads and serves later mounts from the cache', async () => {
|
|
510
|
+
mockUrlAssets.set(`${JAR_BASE}/onejs/assets/a.png`, [0x89]);
|
|
511
|
+
const cs = (globalThis as any).CS;
|
|
512
|
+
const loadSpy = vi.spyOn(cs.OneJS.Network, 'LoadTextureFromUrl');
|
|
513
|
+
|
|
514
|
+
// Two simultaneous mounts of the same src share one request
|
|
515
|
+
const container1 = createMockContainer();
|
|
516
|
+
render(
|
|
517
|
+
<View>
|
|
518
|
+
<Image src="a.png" />
|
|
519
|
+
<Image src="a.png" />
|
|
520
|
+
</View>,
|
|
521
|
+
container1 as any
|
|
522
|
+
);
|
|
523
|
+
await flushMicrotasks();
|
|
524
|
+
|
|
525
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
526
|
+
const view = container1.children[0];
|
|
527
|
+
expect((view.children[0] as any).image).toBeInstanceOf(MockTexture2D);
|
|
528
|
+
expect((view.children[1] as any).image).toBeInstanceOf(MockTexture2D);
|
|
529
|
+
|
|
530
|
+
// A later mount resolves synchronously from the cache
|
|
531
|
+
const container2 = createMockContainer();
|
|
532
|
+
render(<Image src="a.png" />, container2 as any);
|
|
533
|
+
await flushMicrotasks();
|
|
534
|
+
|
|
535
|
+
expect(loadSpy).toHaveBeenCalledTimes(1);
|
|
536
|
+
expect((container2.children[0] as any).image).toBeInstanceOf(MockTexture2D);
|
|
537
|
+
});
|
|
446
538
|
});
|
|
447
539
|
|
|
448
540
|
describe('event handler mapping', () => {
|
package/src/__tests__/mocks.ts
CHANGED
|
@@ -263,11 +263,26 @@ export class MockTexture2D {
|
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Mock VectorImage as produced by SVGUtils.LoadFromString
|
|
268
|
+
*/
|
|
269
|
+
export class MockVectorImage {
|
|
270
|
+
constructor(public svgText: string) {}
|
|
271
|
+
}
|
|
272
|
+
|
|
266
273
|
/**
|
|
267
274
|
* Mock file system for image loading tests.
|
|
268
|
-
* Tests can add entries to control which files "exist" and what
|
|
275
|
+
* Tests can add entries to control which files "exist" and what they contain:
|
|
276
|
+
* number[] entries are raster bytes, string entries are text (e.g. SVG source).
|
|
277
|
+
*/
|
|
278
|
+
export const mockFileSystem = new Map<string, number[] | string>()
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Mock URL-addressable assets for StreamingAssets-as-URL platforms (Android
|
|
282
|
+
* APK jar: URLs, WebGL http URLs). Keyed by full URL; number[] entries are
|
|
283
|
+
* served by Network.LoadTextureFromUrl, string entries by the fetch stub.
|
|
269
284
|
*/
|
|
270
|
-
export const
|
|
285
|
+
export const mockUrlAssets = new Map<string, number[] | string>()
|
|
271
286
|
|
|
272
287
|
/**
|
|
273
288
|
* Create the mock CS global object that mirrors QuickJSBootstrap.js proxy
|
|
@@ -286,7 +301,14 @@ export function createMockCS() {
|
|
|
286
301
|
},
|
|
287
302
|
File: {
|
|
288
303
|
Exists: (path: string) => mockFileSystem.has(path),
|
|
289
|
-
ReadAllBytes: (path: string) =>
|
|
304
|
+
ReadAllBytes: (path: string) => {
|
|
305
|
+
const entry = mockFileSystem.get(path);
|
|
306
|
+
return Array.isArray(entry) ? entry : [];
|
|
307
|
+
},
|
|
308
|
+
ReadAllText: (path: string) => {
|
|
309
|
+
const entry = mockFileSystem.get(path);
|
|
310
|
+
return typeof entry === "string" ? entry : "";
|
|
311
|
+
},
|
|
290
312
|
},
|
|
291
313
|
},
|
|
292
314
|
},
|
|
@@ -345,6 +367,20 @@ export function createMockCS() {
|
|
|
345
367
|
},
|
|
346
368
|
},
|
|
347
369
|
OneJS: {
|
|
370
|
+
SVGUtils: {
|
|
371
|
+
LoadFromString: (svgText: string) => new MockVectorImage(svgText),
|
|
372
|
+
},
|
|
373
|
+
// Mirrors CS.OneJS.Network (Network.cs): LoadTextureFromUrl resolves
|
|
374
|
+
// to a Texture2D on success, null on failure (no throw).
|
|
375
|
+
Network: {
|
|
376
|
+
LoadTextureFromUrl: async (url: string) => {
|
|
377
|
+
const entry = mockUrlAssets.get(url);
|
|
378
|
+
if (!Array.isArray(entry)) return null;
|
|
379
|
+
const tex = new MockTexture2D(2, 2);
|
|
380
|
+
tex.LoadImage(entry);
|
|
381
|
+
return tex;
|
|
382
|
+
},
|
|
383
|
+
},
|
|
348
384
|
GPU: {
|
|
349
385
|
GPUBridge: {
|
|
350
386
|
SetElementBackgroundImage: () => {},
|
|
@@ -368,6 +404,23 @@ export function createMockCS() {
|
|
|
368
404
|
}
|
|
369
405
|
},
|
|
370
406
|
},
|
|
407
|
+
// Mirrors the real CS.OneJS.NodeBridge: resolve element handles and
|
|
408
|
+
// delegate to the same tree ops the slow path would have called.
|
|
409
|
+
NodeBridge: {
|
|
410
|
+
Add: (parentHandle: number, childHandle: number) => {
|
|
411
|
+
const parent = findElementByHandle(parentHandle);
|
|
412
|
+
const child = findElementByHandle(childHandle);
|
|
413
|
+
if (parent && child) parent.Add(child);
|
|
414
|
+
},
|
|
415
|
+
Insert: (parentHandle: number, index: number, childHandle: number) => {
|
|
416
|
+
const parent = findElementByHandle(parentHandle);
|
|
417
|
+
const child = findElementByHandle(childHandle);
|
|
418
|
+
if (parent && child) parent.Insert(index, child);
|
|
419
|
+
},
|
|
420
|
+
RemoveFromHierarchy: (childHandle: number) => {
|
|
421
|
+
findElementByHandle(childHandle)?.RemoveFromHierarchy();
|
|
422
|
+
},
|
|
423
|
+
},
|
|
371
424
|
},
|
|
372
425
|
};
|
|
373
426
|
}
|
|
@@ -392,6 +445,7 @@ export function findElementByHandle(handle: number): MockVisualElement | undefin
|
|
|
392
445
|
export function resetAllMocks(): void {
|
|
393
446
|
createdElements = [];
|
|
394
447
|
mockFileSystem.clear();
|
|
448
|
+
mockUrlAssets.clear();
|
|
395
449
|
}
|
|
396
450
|
|
|
397
451
|
/**
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest"
|
|
2
|
+
import { Painter, batchedVisualContent } from "../painter"
|
|
3
|
+
|
|
4
|
+
// Opcodes mirrored from painter.ts / PainterBridge.cs. These assertions are the
|
|
5
|
+
// guard that the JS recorder and the C# replay engine stay in sync.
|
|
6
|
+
const OP = {
|
|
7
|
+
BeginPath: 1, ClosePath: 2, MoveTo: 3, LineTo: 4, Arc: 5, ArcTo: 6,
|
|
8
|
+
Bezier: 7, Quad: 8, Fill: 9, Stroke: 10, LineWidth: 11, FillColor: 12,
|
|
9
|
+
StrokeColor: 13, LineCap: 14, LineJoin: 15, MiterLimit: 16,
|
|
10
|
+
DashOffset: 17, DashPattern: 18,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("Painter command buffer", () => {
|
|
14
|
+
let lastBuffer: Float32Array | null
|
|
15
|
+
let executeSpy: ReturnType<typeof vi.fn>
|
|
16
|
+
const mgc = {} as never
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
lastBuffer = null
|
|
20
|
+
executeSpy = vi.fn((_mgc: unknown, buffer: Float32Array) => {
|
|
21
|
+
lastBuffer = buffer
|
|
22
|
+
})
|
|
23
|
+
// setup.ts installs a fresh mock CS each test; augment it with PainterBridge.
|
|
24
|
+
;(globalThis as any).CS.OneJS.PainterBridge = { Execute: executeSpy }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("flushes recorded ops as a Float32Array and resets", () => {
|
|
28
|
+
const p = new Painter()
|
|
29
|
+
p.beginPath()
|
|
30
|
+
p.moveTo(10, 20)
|
|
31
|
+
p.lineTo(30, 40)
|
|
32
|
+
p.fill()
|
|
33
|
+
|
|
34
|
+
expect(p.length).toBe(9)
|
|
35
|
+
p.flush(mgc)
|
|
36
|
+
|
|
37
|
+
expect(executeSpy).toHaveBeenCalledTimes(1)
|
|
38
|
+
expect(lastBuffer).toBeInstanceOf(Float32Array)
|
|
39
|
+
expect(Array.from(lastBuffer!)).toEqual([
|
|
40
|
+
OP.BeginPath,
|
|
41
|
+
OP.MoveTo, 10, 20,
|
|
42
|
+
OP.LineTo, 30, 40,
|
|
43
|
+
OP.Fill, 0, // default rule = NonZero
|
|
44
|
+
])
|
|
45
|
+
// Buffer is reset after flush so the next repaint starts clean.
|
|
46
|
+
expect(p.length).toBe(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("applies defaults: arc direction Clockwise (0) and color alpha 1", () => {
|
|
50
|
+
const p = new Painter()
|
|
51
|
+
p.fillColor(0.5, 0.25, 0.125)
|
|
52
|
+
p.arc(100, 100, 80, 0, 0.5)
|
|
53
|
+
p.flush(mgc)
|
|
54
|
+
|
|
55
|
+
expect(Array.from(lastBuffer!)).toEqual([
|
|
56
|
+
OP.FillColor, 0.5, 0.25, 0.125, 1,
|
|
57
|
+
OP.Arc, 100, 100, 80, 0, 0.5, 0, // dir default = Clockwise
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("honors explicit enum options", () => {
|
|
62
|
+
const p = new Painter()
|
|
63
|
+
p.lineCap(Painter.LineCap.Round)
|
|
64
|
+
p.lineJoin(Painter.LineJoin.Bevel)
|
|
65
|
+
p.fill(Painter.FillRule.OddEven)
|
|
66
|
+
p.flush(mgc)
|
|
67
|
+
|
|
68
|
+
expect(Array.from(lastBuffer!)).toEqual([
|
|
69
|
+
OP.LineCap, 1,
|
|
70
|
+
OP.LineJoin, 1,
|
|
71
|
+
OP.Fill, 1,
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("flush is a no-op when nothing was recorded", () => {
|
|
76
|
+
new Painter().flush(mgc)
|
|
77
|
+
expect(executeSpy).not.toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("methods are chainable", () => {
|
|
81
|
+
const p = new Painter()
|
|
82
|
+
const ret = p.beginPath().moveTo(0, 0).lineTo(1, 1)
|
|
83
|
+
expect(ret).toBe(p)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("batchedVisualContent reuses one buffer and does not accumulate across repaints", () => {
|
|
87
|
+
const draw = (p: Painter) => {
|
|
88
|
+
p.beginPath()
|
|
89
|
+
p.moveTo(1, 2)
|
|
90
|
+
}
|
|
91
|
+
const callback = batchedVisualContent(draw)
|
|
92
|
+
|
|
93
|
+
callback(mgc)
|
|
94
|
+
const first = Array.from(lastBuffer!)
|
|
95
|
+
callback(mgc)
|
|
96
|
+
const second = Array.from(lastBuffer!)
|
|
97
|
+
|
|
98
|
+
expect(executeSpy).toHaveBeenCalledTimes(2)
|
|
99
|
+
expect(first).toEqual([OP.BeginPath, OP.MoveTo, 1, 2])
|
|
100
|
+
// Second repaint is identical, not double-length - the painter cleared.
|
|
101
|
+
expect(second).toEqual(first)
|
|
102
|
+
})
|
|
103
|
+
})
|
package/src/__tests__/setup.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { vi, beforeEach, afterEach } from "vitest";
|
|
12
|
-
import { createMockCS, resetAllMocks } from "./mocks";
|
|
12
|
+
import { createMockCS, resetAllMocks, mockUrlAssets } from "./mocks";
|
|
13
13
|
import { clearImageCache } from "../components";
|
|
14
14
|
|
|
15
15
|
// Extend globalThis type for our mocks
|
|
@@ -60,9 +60,20 @@ beforeEach(() => {
|
|
|
60
60
|
// Use real setTimeout/clearTimeout - React scheduler depends on them
|
|
61
61
|
(global as any).setTimeout = originalSetTimeout;
|
|
62
62
|
(global as any).clearTimeout = originalClearTimeout;
|
|
63
|
+
|
|
64
|
+
// Mock fetch serving text entries from mockUrlAssets (mirrors the
|
|
65
|
+
// UnityWebRequest-backed fetch from QuickJSBootstrap.js)
|
|
66
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
67
|
+
const entry = mockUrlAssets.get(url);
|
|
68
|
+
if (typeof entry !== "string") {
|
|
69
|
+
return { ok: false, status: 404, statusText: "Not Found", text: async () => "" };
|
|
70
|
+
}
|
|
71
|
+
return { ok: true, status: 200, statusText: "OK", text: async () => entry };
|
|
72
|
+
}));
|
|
63
73
|
});
|
|
64
74
|
|
|
65
75
|
// Clean up after each test
|
|
66
76
|
afterEach(() => {
|
|
67
77
|
vi.clearAllMocks();
|
|
78
|
+
vi.unstubAllGlobals();
|
|
68
79
|
});
|
package/src/components.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, createElement, useMemo, type ReactElement, type Ref } from 'react';
|
|
1
|
+
import { forwardRef, createElement, useEffect, useMemo, useState, type ReactElement, type Ref } from 'react';
|
|
2
2
|
import type {
|
|
3
3
|
BaseProps,
|
|
4
4
|
ViewProps,
|
|
@@ -33,6 +33,14 @@ useExtensions(CS.UnityEngine.ImageConversion)
|
|
|
33
33
|
|
|
34
34
|
// Module-level image cache shared across all Image instances
|
|
35
35
|
const _imageCache = new Map<string, any>()
|
|
36
|
+
// In-flight async loads keyed by src, so simultaneous mounts share one request
|
|
37
|
+
const _imagePending = new Map<string, Promise<any>>()
|
|
38
|
+
|
|
39
|
+
// On Android, streamingAssetsPath is a jar:file://...apk!/assets URL; on WebGL
|
|
40
|
+
// it's http(s). System.IO.File can't read those - they need UnityWebRequest.
|
|
41
|
+
function _isUrlPath(path: string): boolean {
|
|
42
|
+
return path.includes("://")
|
|
43
|
+
}
|
|
36
44
|
|
|
37
45
|
function _resolveAssetPath(src: string): string {
|
|
38
46
|
const Path = CS.System.IO.Path
|
|
@@ -46,7 +54,11 @@ function _resolveAssetPath(src: string): string {
|
|
|
46
54
|
: Path.Combine(Path.GetDirectoryName(CS.UnityEngine.Application.dataPath), "App")
|
|
47
55
|
return Path.Combine(workingDir, "assets", src)
|
|
48
56
|
}
|
|
49
|
-
|
|
57
|
+
const streamingAssets = CS.UnityEngine.Application.streamingAssetsPath
|
|
58
|
+
if (_isUrlPath(streamingAssets)) {
|
|
59
|
+
return `${streamingAssets}/onejs/assets/${src}`
|
|
60
|
+
}
|
|
61
|
+
return Path.Combine(streamingAssets, "onejs", "assets", src)
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
function _loadImageAsset(src: string): any | null {
|
|
@@ -54,6 +66,9 @@ function _loadImageAsset(src: string): any | null {
|
|
|
54
66
|
if (cached) return cached
|
|
55
67
|
|
|
56
68
|
const fullPath = _resolveAssetPath(src)
|
|
69
|
+
// URL paths (Android APK, WebGL) can't be read synchronously; the Image
|
|
70
|
+
// component falls back to _loadImageAssetAsync for these.
|
|
71
|
+
if (_isUrlPath(fullPath)) return null
|
|
57
72
|
if (!CS.System.IO.File.Exists(fullPath)) {
|
|
58
73
|
console.error(`Image src not found: ${src} (resolved to ${fullPath})`)
|
|
59
74
|
return null
|
|
@@ -75,12 +90,52 @@ function _loadImageAsset(src: string): any | null {
|
|
|
75
90
|
return result
|
|
76
91
|
}
|
|
77
92
|
|
|
93
|
+
function _loadImageAssetAsync(src: string, url: string): Promise<any> {
|
|
94
|
+
const existing = _imagePending.get(src)
|
|
95
|
+
if (existing) return existing
|
|
96
|
+
|
|
97
|
+
const promise = (async (): Promise<any> => {
|
|
98
|
+
try {
|
|
99
|
+
let result: any
|
|
100
|
+
if (src.toLowerCase().endsWith(".svg")) {
|
|
101
|
+
const res = await fetch(url)
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
console.error(`Image src not found: ${src} (resolved to ${url})`)
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
const svgText = await res.text()
|
|
107
|
+
result = CS.OneJS.SVGUtils.LoadFromString(svgText)
|
|
108
|
+
} else {
|
|
109
|
+
const tex = await CS.OneJS.Network.LoadTextureFromUrl(url)
|
|
110
|
+
if (!tex) {
|
|
111
|
+
console.error(`Image src not found: ${src} (resolved to ${url})`)
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
tex.filterMode = CS.UnityEngine.FilterMode.Bilinear
|
|
115
|
+
result = tex
|
|
116
|
+
}
|
|
117
|
+
_imageCache.set(src, result)
|
|
118
|
+
return result
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error(`Image src failed to load: ${src} (resolved to ${url}): ${e}`)
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
})()
|
|
124
|
+
|
|
125
|
+
_imagePending.set(src, promise)
|
|
126
|
+
promise.then(() => {
|
|
127
|
+
if (_imagePending.get(src) === promise) _imagePending.delete(src)
|
|
128
|
+
})
|
|
129
|
+
return promise
|
|
130
|
+
}
|
|
131
|
+
|
|
78
132
|
/**
|
|
79
133
|
* Clear the Image component's image cache.
|
|
80
134
|
* Call this if you need to force-reload images (e.g., after replacing files on disk).
|
|
81
135
|
*/
|
|
82
136
|
export function clearImageCache(): void {
|
|
83
137
|
_imageCache.clear()
|
|
138
|
+
_imagePending.clear()
|
|
84
139
|
}
|
|
85
140
|
|
|
86
141
|
// Props with ref support for intrinsic elements
|
|
@@ -170,11 +225,27 @@ export const ScrollView = forwardRef<ScrollViewElement, ScrollViewProps>((props,
|
|
|
170
225
|
ScrollView.displayName = 'ScrollView';
|
|
171
226
|
|
|
172
227
|
export const Image = forwardRef<ImageElement, ImageProps>(({ src, image, ...rest }, ref) => {
|
|
228
|
+
const [loaded, setLoaded] = useState<{ src: string; image: any } | null>(null)
|
|
173
229
|
const resolved = useMemo(() => {
|
|
174
230
|
if (src) return _loadImageAsset(src)
|
|
175
231
|
return image
|
|
176
232
|
}, [src, image])
|
|
177
|
-
|
|
233
|
+
|
|
234
|
+
// Async fallback for platforms where StreamingAssets is a URL (Android APK,
|
|
235
|
+
// WebGL): load via UnityWebRequest, then re-render with the result.
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!src || resolved) return
|
|
238
|
+
const fullPath = _resolveAssetPath(src)
|
|
239
|
+
if (!_isUrlPath(fullPath)) return
|
|
240
|
+
let cancelled = false
|
|
241
|
+
_loadImageAssetAsync(src, fullPath).then((result) => {
|
|
242
|
+
if (!cancelled && result) setLoaded({ src, image: result })
|
|
243
|
+
})
|
|
244
|
+
return () => { cancelled = true }
|
|
245
|
+
}, [src, resolved])
|
|
246
|
+
|
|
247
|
+
const asyncImage = loaded && loaded.src === src ? loaded.image : null
|
|
248
|
+
return <ojs-image ref={ref} image={resolved ?? asyncImage} {...rest} />;
|
|
178
249
|
});
|
|
179
250
|
Image.displayName = 'Image';
|
|
180
251
|
|
package/src/host-config.ts
CHANGED
|
@@ -75,6 +75,11 @@ declare const CS: {
|
|
|
75
75
|
ApplyStyles: (element: CSObject, styles: Record<string, unknown>) => void;
|
|
76
76
|
AddClassesBatch: (element: CSObject, classes: string[]) => void;
|
|
77
77
|
};
|
|
78
|
+
NodeBridge: {
|
|
79
|
+
Add: (parentHandle: number, childHandle: number) => void;
|
|
80
|
+
Insert: (parentHandle: number, index: number, childHandle: number) => void;
|
|
81
|
+
RemoveFromHierarchy: (childHandle: number) => void;
|
|
82
|
+
};
|
|
78
83
|
};
|
|
79
84
|
};
|
|
80
85
|
|
|
@@ -229,7 +234,6 @@ const EVENT_PROPS: Record<string, string> = {
|
|
|
229
234
|
onPointerCancel: 'pointercancel',
|
|
230
235
|
onPointerCapture: 'pointercapture',
|
|
231
236
|
onPointerCaptureOut: 'pointercaptureout',
|
|
232
|
-
onPointerStationary: 'pointerstationary',
|
|
233
237
|
|
|
234
238
|
// Mouse events
|
|
235
239
|
onMouseDown: 'mousedown',
|
|
@@ -571,6 +575,35 @@ function untrackParent(child: CSObject) {
|
|
|
571
575
|
}
|
|
572
576
|
}
|
|
573
577
|
|
|
578
|
+
// Tree wiring routes through CS.OneJS.NodeBridge, a zero-alloc fast path, instead
|
|
579
|
+
// of the per-element VisualElement.Add/Insert/RemoveFromHierarchy reflection calls.
|
|
580
|
+
// Handles are read from the proxy's __csHandle (a JS-side field, no crossing).
|
|
581
|
+
// Falls back to the direct proxy call when a handle isn't available (e.g. a
|
|
582
|
+
// container that isn't a handle-tracked element).
|
|
583
|
+
function elementHandle(el: CSObject): number {
|
|
584
|
+
return (el as unknown as { __csHandle?: number }).__csHandle ?? -1;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function nodeAdd(parentEl: CSObject, childEl: CSObject) {
|
|
588
|
+
const ph = elementHandle(parentEl);
|
|
589
|
+
const ch = elementHandle(childEl);
|
|
590
|
+
if (ph > 0 && ch > 0) CS.OneJS.NodeBridge.Add(ph, ch);
|
|
591
|
+
else parentEl.Add(childEl);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function nodeInsert(parentEl: CSObject, index: number, childEl: CSObject) {
|
|
595
|
+
const ph = elementHandle(parentEl);
|
|
596
|
+
const ch = elementHandle(childEl);
|
|
597
|
+
if (ph > 0 && ch > 0) CS.OneJS.NodeBridge.Insert(ph, index, ch);
|
|
598
|
+
else parentEl.Insert(index, childEl);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function nodeRemoveFromHierarchy(childEl: CSObject) {
|
|
602
|
+
const ch = elementHandle(childEl);
|
|
603
|
+
if (ch > 0) CS.OneJS.NodeBridge.RemoveFromHierarchy(ch);
|
|
604
|
+
else childEl.RemoveFromHierarchy();
|
|
605
|
+
}
|
|
606
|
+
|
|
574
607
|
|
|
575
608
|
// Apply event handlers
|
|
576
609
|
function applyEvents(instance: Instance, props: BaseProps) {
|
|
@@ -655,7 +688,7 @@ function unmergTextChildren(parentInstance: Instance) {
|
|
|
655
688
|
// Add each merged text child as an actual visual child
|
|
656
689
|
for (const child of children) {
|
|
657
690
|
child.mergedInto = undefined;
|
|
658
|
-
parentInstance.element
|
|
691
|
+
nodeAdd(parentInstance.element, child.element);
|
|
659
692
|
}
|
|
660
693
|
|
|
661
694
|
// Clear the merged children list
|
|
@@ -727,12 +760,12 @@ function insertElementBefore(parentEl: CSObject, childEl: CSObject, beforeChildE
|
|
|
727
760
|
if (beforeIndex < 0) {
|
|
728
761
|
// beforeChild isn't actually in the parent (should not normally happen).
|
|
729
762
|
// Fall back to appending, preserving the previous fallback behavior.
|
|
730
|
-
parentEl
|
|
763
|
+
nodeAdd(parentEl, childEl);
|
|
731
764
|
return;
|
|
732
765
|
}
|
|
733
766
|
const childIndex = parentEl.IndexOf(childEl);
|
|
734
767
|
const target = childIndex >= 0 && childIndex < beforeIndex ? beforeIndex - 1 : beforeIndex;
|
|
735
|
-
parentEl
|
|
768
|
+
nodeInsert(parentEl, target, childEl);
|
|
736
769
|
}
|
|
737
770
|
|
|
738
771
|
// MARK: Component-specific prop handlers
|
|
@@ -1092,7 +1125,7 @@ export const hostConfig = {
|
|
|
1092
1125
|
appendMergedTextChild(parentInstance, child);
|
|
1093
1126
|
} else {
|
|
1094
1127
|
handleNonTextChild(parentInstance);
|
|
1095
|
-
parentInstance.element
|
|
1128
|
+
nodeAdd(parentInstance.element, child.element);
|
|
1096
1129
|
}
|
|
1097
1130
|
trackParent(child.element, parentInstance.element);
|
|
1098
1131
|
},
|
|
@@ -1102,13 +1135,13 @@ export const hostConfig = {
|
|
|
1102
1135
|
appendMergedTextChild(parentInstance, child);
|
|
1103
1136
|
} else {
|
|
1104
1137
|
handleNonTextChild(parentInstance);
|
|
1105
|
-
parentInstance.element
|
|
1138
|
+
nodeAdd(parentInstance.element, child.element);
|
|
1106
1139
|
}
|
|
1107
1140
|
trackParent(child.element, parentInstance.element);
|
|
1108
1141
|
},
|
|
1109
1142
|
|
|
1110
1143
|
appendChildToContainer(container: Container, child: Instance) {
|
|
1111
|
-
container
|
|
1144
|
+
nodeAdd(container, child.element);
|
|
1112
1145
|
// Container is the root - no parent to track
|
|
1113
1146
|
},
|
|
1114
1147
|
|
|
@@ -1136,7 +1169,7 @@ export const hostConfig = {
|
|
|
1136
1169
|
// is a safe no-op if it's already detached. Using it instead of
|
|
1137
1170
|
// parentInstance.element.Remove(child.element) keeps unmount from throwing
|
|
1138
1171
|
// when the root was cleared before React tore the tree down (hot reload).
|
|
1139
|
-
child.element
|
|
1172
|
+
nodeRemoveFromHierarchy(child.element);
|
|
1140
1173
|
}
|
|
1141
1174
|
untrackParent(child.element);
|
|
1142
1175
|
},
|
|
@@ -1145,7 +1178,7 @@ export const hostConfig = {
|
|
|
1145
1178
|
__eventAPI.removeAllEventListeners(child.element);
|
|
1146
1179
|
// See removeChild: tolerant of an already-detached element so a hot-reload
|
|
1147
1180
|
// teardown (which clears the root first) can still unmount cleanly.
|
|
1148
|
-
child.element
|
|
1181
|
+
nodeRemoveFromHierarchy(child.element);
|
|
1149
1182
|
untrackParent(child.element);
|
|
1150
1183
|
},
|
|
1151
1184
|
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,9 @@ export type {
|
|
|
48
48
|
// Vector Drawing
|
|
49
49
|
export { Transform2D, useVectorContent } from './vector';
|
|
50
50
|
|
|
51
|
+
// Batched vector drawing - single-crossing command buffer (see painter.ts)
|
|
52
|
+
export { Painter, batchedVisualContent, useBatchedVectorContent } from './painter';
|
|
53
|
+
|
|
51
54
|
// Sync Hooks & C# Interop Utilities
|
|
52
55
|
export { useFrameSync, useFrameSyncWith, useThrottledSync, useEventSync, toArray } from './hooks';
|
|
53
56
|
export type { EventSource } from './hooks';
|
package/src/painter.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched vector drawing for OneJS.
|
|
3
|
+
*
|
|
4
|
+
* A Painter records drawing ops into a flat numeric buffer and flushes them to
|
|
5
|
+
* C# in a single crossing via CS.OneJS.PainterBridge.Execute, instead of making
|
|
6
|
+
* one reflection crossing per Painter2D call (and per `new Vector2`/`new Color`)
|
|
7
|
+
* the way raw mgc.painter2D usage does. On the QuickJS interpreter those
|
|
8
|
+
* per-op crossings are the dominant cost of custom vector drawing.
|
|
9
|
+
*
|
|
10
|
+
* For the common case, wrap your draw function with batchedVisualContent so the
|
|
11
|
+
* flush is automatic:
|
|
12
|
+
*
|
|
13
|
+
* import { batchedVisualContent } from "onejs-react"
|
|
14
|
+
*
|
|
15
|
+
* <View
|
|
16
|
+
* style={{ width: 200, height: 200 }}
|
|
17
|
+
* onGenerateVisualContent={batchedVisualContent((p) => {
|
|
18
|
+
* p.fillColor(1, 0, 0, 1)
|
|
19
|
+
* p.beginPath()
|
|
20
|
+
* p.arc(100, 100, 80, 0, Math.PI * 2)
|
|
21
|
+
* p.fill()
|
|
22
|
+
* })}
|
|
23
|
+
* />
|
|
24
|
+
*
|
|
25
|
+
* Gradients, textures, and DrawText are not part of the buffer; for those keep
|
|
26
|
+
* using the raw mgc.painter2D / mgc API.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useRef, useEffect, type DependencyList, type RefObject } from "react"
|
|
30
|
+
import type { VisualElement, MeshGenerationContext, GenerateVisualContentCallback } from "./types"
|
|
31
|
+
|
|
32
|
+
declare const CS: {
|
|
33
|
+
OneJS: {
|
|
34
|
+
PainterBridge: {
|
|
35
|
+
Execute: (mgc: MeshGenerationContext, buffer: Float32Array) => void
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Opcode contract - must match Assets/Singtaa/OneJS/Runtime/PainterBridge.cs.
|
|
41
|
+
const OP_BEGIN_PATH = 1
|
|
42
|
+
const OP_CLOSE_PATH = 2
|
|
43
|
+
const OP_MOVE_TO = 3
|
|
44
|
+
const OP_LINE_TO = 4
|
|
45
|
+
const OP_ARC = 5
|
|
46
|
+
const OP_ARC_TO = 6
|
|
47
|
+
const OP_BEZIER_CURVE_TO = 7
|
|
48
|
+
const OP_QUADRATIC_CURVE_TO = 8
|
|
49
|
+
const OP_FILL = 9
|
|
50
|
+
const OP_STROKE = 10
|
|
51
|
+
const OP_LINE_WIDTH = 11
|
|
52
|
+
const OP_FILL_COLOR = 12
|
|
53
|
+
const OP_STROKE_COLOR = 13
|
|
54
|
+
const OP_LINE_CAP = 14
|
|
55
|
+
const OP_LINE_JOIN = 15
|
|
56
|
+
const OP_MITER_LIMIT = 16
|
|
57
|
+
const OP_DASH_OFFSET = 17
|
|
58
|
+
const OP_DASH_PATTERN = 18
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Records Painter2D drawing ops into a numeric buffer for batched playback.
|
|
62
|
+
*
|
|
63
|
+
* Methods are chainable. Coordinates and colors are plain numbers (no CS object
|
|
64
|
+
* construction), which is the whole point - the buffer crosses to C# once and
|
|
65
|
+
* the structs are built C#-side.
|
|
66
|
+
*
|
|
67
|
+
* Enum-like options live as statics so they do not collide with the CS enum
|
|
68
|
+
* type aliases (ArcDirection, FillRule, ...) re-exported from the package root:
|
|
69
|
+
* p.arc(..., Painter.ArcDirection.CounterClockwise)
|
|
70
|
+
* p.fill(Painter.FillRule.OddEven)
|
|
71
|
+
*/
|
|
72
|
+
export class Painter {
|
|
73
|
+
/** Stroke cap style. Values are the buffer contract, not Unity enum values. */
|
|
74
|
+
static readonly LineCap = { Butt: 0, Round: 1 } as const
|
|
75
|
+
/** Stroke join style. */
|
|
76
|
+
static readonly LineJoin = { Miter: 0, Bevel: 1, Round: 2 } as const
|
|
77
|
+
/** Fill rule for self-intersecting paths. */
|
|
78
|
+
static readonly FillRule = { NonZero: 0, OddEven: 1 } as const
|
|
79
|
+
/** Arc sweep direction. */
|
|
80
|
+
static readonly ArcDirection = { Clockwise: 0, CounterClockwise: 1 } as const
|
|
81
|
+
|
|
82
|
+
private _buf: number[] = []
|
|
83
|
+
|
|
84
|
+
/** Discard all recorded ops, keeping allocated capacity for reuse. */
|
|
85
|
+
clear(): this {
|
|
86
|
+
this._buf.length = 0
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Number of recorded floats. Useful for diagnostics and tests. */
|
|
91
|
+
get length(): number {
|
|
92
|
+
return this._buf.length
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beginPath(): this { this._buf.push(OP_BEGIN_PATH); return this }
|
|
96
|
+
closePath(): this { this._buf.push(OP_CLOSE_PATH); return this }
|
|
97
|
+
moveTo(x: number, y: number): this { this._buf.push(OP_MOVE_TO, x, y); return this }
|
|
98
|
+
lineTo(x: number, y: number): this { this._buf.push(OP_LINE_TO, x, y); return this }
|
|
99
|
+
|
|
100
|
+
/** Arc with center (cx, cy). Angles in radians. dir uses Painter.ArcDirection. */
|
|
101
|
+
arc(cx: number, cy: number, radius: number, startAngle: number, endAngle: number, dir: number = 0 /* Clockwise */): this {
|
|
102
|
+
this._buf.push(OP_ARC, cx, cy, radius, startAngle, endAngle, dir)
|
|
103
|
+
return this
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): this {
|
|
107
|
+
this._buf.push(OP_ARC_TO, x1, y1, x2, y2, radius)
|
|
108
|
+
return this
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
|
|
112
|
+
this._buf.push(OP_BEZIER_CURVE_TO, c1x, c1y, c2x, c2y, x, y)
|
|
113
|
+
return this
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
quadraticCurveTo(cx: number, cy: number, x: number, y: number): this {
|
|
117
|
+
this._buf.push(OP_QUADRATIC_CURVE_TO, cx, cy, x, y)
|
|
118
|
+
return this
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fill the current path. rule uses Painter.FillRule (default NonZero). */
|
|
122
|
+
fill(rule: number = 0 /* NonZero */): this { this._buf.push(OP_FILL, rule); return this }
|
|
123
|
+
stroke(): this { this._buf.push(OP_STROKE); return this }
|
|
124
|
+
|
|
125
|
+
lineWidth(w: number): this { this._buf.push(OP_LINE_WIDTH, w); return this }
|
|
126
|
+
fillColor(r: number, g: number, b: number, a: number = 1): this { this._buf.push(OP_FILL_COLOR, r, g, b, a); return this }
|
|
127
|
+
strokeColor(r: number, g: number, b: number, a: number = 1): this { this._buf.push(OP_STROKE_COLOR, r, g, b, a); return this }
|
|
128
|
+
lineCap(cap: number): this { this._buf.push(OP_LINE_CAP, cap); return this }
|
|
129
|
+
lineJoin(join: number): this { this._buf.push(OP_LINE_JOIN, join); return this }
|
|
130
|
+
miterLimit(limit: number): this { this._buf.push(OP_MITER_LIMIT, limit); return this }
|
|
131
|
+
dashOffset(offset: number): this { this._buf.push(OP_DASH_OFFSET, offset); return this }
|
|
132
|
+
dashPattern(dash: number, gap: number): this { this._buf.push(OP_DASH_PATTERN, dash, gap); return this }
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Send all recorded ops to C# in one crossing, then reset the buffer.
|
|
136
|
+
* No-op when nothing was recorded.
|
|
137
|
+
*/
|
|
138
|
+
flush(mgc: MeshGenerationContext): void {
|
|
139
|
+
if (this._buf.length === 0) return
|
|
140
|
+
CS.OneJS.PainterBridge.Execute(mgc, Float32Array.from(this._buf))
|
|
141
|
+
this._buf.length = 0
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Wrap a draw function so it records into a reused Painter and auto-flushes in
|
|
147
|
+
* one crossing after each repaint. The recommended entry point for batched
|
|
148
|
+
* drawing - assign the result straight to onGenerateVisualContent.
|
|
149
|
+
*/
|
|
150
|
+
export function batchedVisualContent(draw: (p: Painter) => void): GenerateVisualContentCallback {
|
|
151
|
+
const painter = new Painter()
|
|
152
|
+
return (mgc: MeshGenerationContext) => {
|
|
153
|
+
painter.clear()
|
|
154
|
+
draw(painter)
|
|
155
|
+
painter.flush(mgc)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Batched counterpart to useVectorContent. Returns a ref to attach to a
|
|
161
|
+
* VisualElement; the draw callback records into a reused Painter that flushes in
|
|
162
|
+
* a single crossing. Repaints automatically when deps change.
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* const ref = useBatchedVectorContent((p) => {
|
|
166
|
+
* p.fillColor(1, 0, 0, 1)
|
|
167
|
+
* p.beginPath()
|
|
168
|
+
* p.arc(100, 100, radius, 0, Math.PI * 2)
|
|
169
|
+
* p.fill()
|
|
170
|
+
* }, [radius])
|
|
171
|
+
* return <View ref={ref} style={{ width: 200, height: 200 }} />
|
|
172
|
+
*/
|
|
173
|
+
export function useBatchedVectorContent(
|
|
174
|
+
draw: (p: Painter) => void,
|
|
175
|
+
deps: DependencyList = []
|
|
176
|
+
): RefObject<VisualElement | null> {
|
|
177
|
+
const ref = useRef<VisualElement | null>(null)
|
|
178
|
+
const drawRef = useRef(draw)
|
|
179
|
+
drawRef.current = draw
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const element = ref.current
|
|
183
|
+
if (!element) return
|
|
184
|
+
|
|
185
|
+
const painter = new Painter()
|
|
186
|
+
const callback: GenerateVisualContentCallback = (mgc) => {
|
|
187
|
+
painter.clear()
|
|
188
|
+
drawRef.current(painter)
|
|
189
|
+
painter.flush(mgc)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const el = element as unknown as { generateVisualContent: GenerateVisualContentCallback | null }
|
|
193
|
+
el.generateVisualContent = callback
|
|
194
|
+
element.MarkDirtyRepaint()
|
|
195
|
+
|
|
196
|
+
return () => {
|
|
197
|
+
el.generateVisualContent = null
|
|
198
|
+
}
|
|
199
|
+
}, [])
|
|
200
|
+
|
|
201
|
+
const isFirstRender = useRef(true)
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (isFirstRender.current) {
|
|
204
|
+
isFirstRender.current = false
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
const element = ref.current
|
|
208
|
+
if (element) element.MarkDirtyRepaint()
|
|
209
|
+
}, deps)
|
|
210
|
+
|
|
211
|
+
return ref
|
|
212
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -436,7 +436,6 @@ export interface BaseProps {
|
|
|
436
436
|
onPointerCancel?: PointerEventHandler;
|
|
437
437
|
onPointerCapture?: PointerEventHandler;
|
|
438
438
|
onPointerCaptureOut?: PointerEventHandler;
|
|
439
|
-
onPointerStationary?: PointerEventHandler;
|
|
440
439
|
|
|
441
440
|
// Mouse events
|
|
442
441
|
onMouseDown?: MouseEventHandler;
|