onejs-react 0.1.29 → 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 +40 -3
- package/src/__tests__/setup.ts +12 -1
- package/src/components.tsx +74 -3
- package/src/host-config.ts +0 -1
- 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: () => {},
|
|
@@ -409,6 +445,7 @@ export function findElementByHandle(handle: number): MockVisualElement | undefin
|
|
|
409
445
|
export function resetAllMocks(): void {
|
|
410
446
|
createdElements = [];
|
|
411
447
|
mockFileSystem.clear();
|
|
448
|
+
mockUrlAssets.clear();
|
|
412
449
|
}
|
|
413
450
|
|
|
414
451
|
/**
|
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
|
@@ -234,7 +234,6 @@ const EVENT_PROPS: Record<string, string> = {
|
|
|
234
234
|
onPointerCancel: 'pointercancel',
|
|
235
235
|
onPointerCapture: 'pointercapture',
|
|
236
236
|
onPointerCaptureOut: 'pointercaptureout',
|
|
237
|
-
onPointerStationary: 'pointerstationary',
|
|
238
237
|
|
|
239
238
|
// Mouse events
|
|
240
239
|
onMouseDown: 'mousedown',
|
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;
|