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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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', () => {
@@ -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 bytes they contain.
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 mockFileSystem = new Map<string, number[]>()
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) => mockFileSystem.get(path) || [],
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
+ })
@@ -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
  });
@@ -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
- return Path.Combine(CS.UnityEngine.Application.streamingAssetsPath, "onejs", "assets", src)
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
- return <ojs-image ref={ref} image={resolved} {...rest} />;
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
 
@@ -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.Add(child.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.Add(childEl);
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.Insert(target, childEl);
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.Add(child.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.Add(child.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.Add(child.element);
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.RemoveFromHierarchy();
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.RemoveFromHierarchy();
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;