onejs-react 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,436 @@
1
+ /**
2
+ * End-to-end tests for collection state sync patterns.
3
+ *
4
+ * Simulates the real-world use case of syncing dynamic C# game state
5
+ * (inventories, quest logs, NPC lists) to React components using
6
+ * useFrameSync selector mode + toArray.
7
+ *
8
+ * Tests the parent/child pattern:
9
+ * - Parent watches list structure (Count) via selector
10
+ * - Child components each watch their own item properties via selector
11
+ * - When an item property changes, only that child re-renders
12
+ * - When items are added/removed, the parent re-renders
13
+ */
14
+
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
16
+ import React from "react"
17
+ import { useFrameSync, toArray } from "../hooks"
18
+ import { render, unmount } from "../renderer"
19
+ import { createMockContainer, flushMicrotasks } from "./mocks"
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // RAF mock
23
+ // ---------------------------------------------------------------------------
24
+
25
+ type RafCallback = (time: number) => void
26
+
27
+ let rafQueue: Array<{ id: number; callback: RafCallback }> = []
28
+ let nextRafId = 0
29
+
30
+ function flushRaf() {
31
+ const queue = [...rafQueue]
32
+ rafQueue = []
33
+ const now = Date.now()
34
+ for (const { callback } of queue) {
35
+ callback(now)
36
+ }
37
+ }
38
+
39
+ async function advanceFrame() {
40
+ flushRaf()
41
+ await flushMicrotasks()
42
+ }
43
+
44
+ beforeEach(() => {
45
+ rafQueue = []
46
+ nextRafId = 0
47
+ ;(globalThis as any).requestAnimationFrame = vi.fn((cb: RafCallback) => {
48
+ const id = ++nextRafId
49
+ rafQueue.push({ id, callback: cb })
50
+ return id
51
+ })
52
+ ;(globalThis as any).cancelAnimationFrame = vi.fn((id: number) => {
53
+ rafQueue = rafQueue.filter((e) => e.id !== id)
54
+ })
55
+ })
56
+
57
+ afterEach(() => {
58
+ delete (globalThis as any).requestAnimationFrame
59
+ delete (globalThis as any).cancelAnimationFrame
60
+ })
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Mock C# proxy objects — simulates proxy caching behavior
64
+ //
65
+ // In real OneJS, accessing a C# object from JS always returns the same
66
+ // proxy reference (cached by handle). Properties read through to C# on
67
+ // each access, so mutations are visible through the same reference.
68
+ // ---------------------------------------------------------------------------
69
+
70
+ interface MockItem {
71
+ Id: number
72
+ Name: string
73
+ Durability: number
74
+ StackCount: number
75
+ Version: number
76
+ }
77
+
78
+ interface MockInventory {
79
+ Items: { Count: number; [index: number]: MockItem }
80
+ }
81
+
82
+ function createMockInventory(items: MockItem[]): MockInventory {
83
+ // Simulates a C# List<Item> — same proxy object, Count and indexer
84
+ // read live values (mutating the items array is visible immediately)
85
+ const proxy: any = {
86
+ get Count() { return items.length },
87
+ }
88
+ // Numeric indexer — proxy reads live from the array
89
+ return {
90
+ Items: new Proxy(proxy, {
91
+ get(target, prop) {
92
+ if (prop === "Count") return items.length
93
+ const idx = Number(prop)
94
+ if (!isNaN(idx) && idx >= 0 && idx < items.length) {
95
+ return items[idx]
96
+ }
97
+ return target[prop]
98
+ }
99
+ })
100
+ }
101
+ }
102
+
103
+ function createMockItem(id: number, name: string, durability: number): MockItem {
104
+ return { Id: id, Name: name, Durability: durability, StackCount: 1, Version: 1 }
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Tests
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("collection sync: parent/child pattern", () => {
112
+ let container: ReturnType<typeof createMockContainer>
113
+
114
+ beforeEach(() => {
115
+ container = createMockContainer()
116
+ })
117
+
118
+ afterEach(() => {
119
+ unmount(container as any)
120
+ })
121
+
122
+ it("parent re-renders when items are added, children render per item", async () => {
123
+ const items: MockItem[] = [
124
+ createMockItem(1, "Sword", 100),
125
+ createMockItem(2, "Shield", 80),
126
+ ]
127
+ const inventory = createMockInventory(items)
128
+
129
+ let parentRenders = 0
130
+ const childRenders: Record<number, number> = {}
131
+
132
+ function ItemView({ item }: { item: MockItem }) {
133
+ const data = useFrameSync(
134
+ () => item,
135
+ (i) => [i.Name, i.Durability, i.StackCount]
136
+ )
137
+ childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
138
+ return null
139
+ }
140
+
141
+ function InventoryList() {
142
+ const inv = useFrameSync(
143
+ () => inventory,
144
+ (i) => [i.Items.Count]
145
+ )
146
+ parentRenders++
147
+ return (
148
+ <>
149
+ {toArray<MockItem>(inv.Items).map(item => (
150
+ <ItemView key={item.Id} item={item} />
151
+ ))}
152
+ </>
153
+ )
154
+ }
155
+
156
+ render(<InventoryList />, container as any)
157
+ await flushMicrotasks()
158
+ await advanceFrame()
159
+
160
+ expect(parentRenders).toBeGreaterThanOrEqual(1)
161
+ expect(childRenders[1]).toBeGreaterThanOrEqual(1)
162
+ expect(childRenders[2]).toBeGreaterThanOrEqual(1)
163
+
164
+ const parentRendersAfterInit = parentRenders
165
+ const child1RendersAfterInit = childRenders[1]
166
+ const child2RendersAfterInit = childRenders[2]
167
+
168
+ // Add a new item
169
+ items.push(createMockItem(3, "Potion", 1))
170
+ await advanceFrame()
171
+
172
+ // Parent should re-render (Count changed from 2 to 3)
173
+ expect(parentRenders).toBeGreaterThan(parentRendersAfterInit)
174
+ // New child should have rendered
175
+ expect(childRenders[3]).toBeGreaterThanOrEqual(1)
176
+ })
177
+
178
+ it("only the affected child re-renders when an item property changes", async () => {
179
+ const items: MockItem[] = [
180
+ createMockItem(1, "Sword", 100),
181
+ createMockItem(2, "Shield", 80),
182
+ createMockItem(3, "Potion", 1),
183
+ ]
184
+ const inventory = createMockInventory(items)
185
+
186
+ let parentRenders = 0
187
+ const childRenders: Record<number, number> = {}
188
+
189
+ function ItemView({ item }: { item: MockItem }) {
190
+ const data = useFrameSync(
191
+ () => item,
192
+ (i) => [i.Name, i.Durability, i.StackCount]
193
+ )
194
+ childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
195
+ return null
196
+ }
197
+
198
+ function InventoryList() {
199
+ const inv = useFrameSync(
200
+ () => inventory,
201
+ (i) => [i.Items.Count]
202
+ )
203
+ parentRenders++
204
+ return (
205
+ <>
206
+ {toArray<MockItem>(inv.Items).map(item => (
207
+ <ItemView key={item.Id} item={item} />
208
+ ))}
209
+ </>
210
+ )
211
+ }
212
+
213
+ render(<InventoryList />, container as any)
214
+ await flushMicrotasks()
215
+ await advanceFrame()
216
+
217
+ const parentRendersAfterInit = parentRenders
218
+ const child1After = childRenders[1]
219
+ const child2After = childRenders[2]
220
+ const child3After = childRenders[3]
221
+
222
+ // Change only Sword's durability (simulates using the item)
223
+ items[0].Durability = 90
224
+ await advanceFrame()
225
+
226
+ // Parent should NOT re-render (Count unchanged)
227
+ expect(parentRenders).toBe(parentRendersAfterInit)
228
+ // Only Sword's child should re-render
229
+ expect(childRenders[1]).toBeGreaterThan(child1After)
230
+ // Shield and Potion should NOT re-render
231
+ expect(childRenders[2]).toBe(child2After)
232
+ expect(childRenders[3]).toBe(child3After)
233
+ })
234
+
235
+ it("version stamp on items catches any property change", async () => {
236
+ const items: MockItem[] = [
237
+ createMockItem(1, "Sword", 100),
238
+ createMockItem(2, "Shield", 80),
239
+ ]
240
+ const inventory = createMockInventory(items)
241
+
242
+ const childRenders: Record<number, number> = {}
243
+
244
+ function ItemView({ item }: { item: MockItem }) {
245
+ // Watch only Version — catches all changes without listing every property
246
+ const data = useFrameSync(
247
+ () => item,
248
+ (i) => [i.Version]
249
+ )
250
+ childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
251
+ return null
252
+ }
253
+
254
+ function InventoryList() {
255
+ const inv = useFrameSync(
256
+ () => inventory,
257
+ (i) => [i.Items.Count]
258
+ )
259
+ return (
260
+ <>
261
+ {toArray<MockItem>(inv.Items).map(item => (
262
+ <ItemView key={item.Id} item={item} />
263
+ ))}
264
+ </>
265
+ )
266
+ }
267
+
268
+ render(<InventoryList />, container as any)
269
+ await flushMicrotasks()
270
+ await advanceFrame()
271
+
272
+ const child1After = childRenders[1]
273
+ const child2After = childRenders[2]
274
+
275
+ // Change Sword's durability AND bump its version (simulates Fody)
276
+ items[0].Durability = 90
277
+ items[0].Version = 2
278
+ await advanceFrame()
279
+
280
+ // Sword re-renders (version changed)
281
+ expect(childRenders[1]).toBeGreaterThan(child1After)
282
+ // Shield does NOT re-render (version unchanged)
283
+ expect(childRenders[2]).toBe(child2After)
284
+ })
285
+
286
+ it("handles item removal correctly", async () => {
287
+ const items: MockItem[] = [
288
+ createMockItem(1, "Sword", 100),
289
+ createMockItem(2, "Shield", 80),
290
+ createMockItem(3, "Potion", 1),
291
+ ]
292
+ const inventory = createMockInventory(items)
293
+
294
+ let parentRenders = 0
295
+ const childRenders: Record<number, number> = {}
296
+
297
+ function ItemView({ item }: { item: MockItem }) {
298
+ const data = useFrameSync(
299
+ () => item,
300
+ (i) => [i.Name, i.Durability]
301
+ )
302
+ childRenders[data.Id] = (childRenders[data.Id] || 0) + 1
303
+ return null
304
+ }
305
+
306
+ function InventoryList() {
307
+ const inv = useFrameSync(
308
+ () => inventory,
309
+ (i) => [i.Items.Count]
310
+ )
311
+ parentRenders++
312
+ return (
313
+ <>
314
+ {toArray<MockItem>(inv.Items).map(item => (
315
+ <ItemView key={item.Id} item={item} />
316
+ ))}
317
+ </>
318
+ )
319
+ }
320
+
321
+ render(<InventoryList />, container as any)
322
+ await flushMicrotasks()
323
+ await advanceFrame()
324
+
325
+ const parentRendersAfterInit = parentRenders
326
+ expect(childRenders[1]).toBeGreaterThanOrEqual(1)
327
+ expect(childRenders[2]).toBeGreaterThanOrEqual(1)
328
+ expect(childRenders[3]).toBeGreaterThanOrEqual(1)
329
+
330
+ // Remove Shield (index 1)
331
+ items.splice(1, 1)
332
+ await advanceFrame()
333
+
334
+ // Parent should re-render (Count changed from 3 to 2)
335
+ expect(parentRenders).toBeGreaterThan(parentRendersAfterInit)
336
+ })
337
+
338
+ it("nullable parent with optional chaining works end-to-end", async () => {
339
+ let currentPlace: { Name: string; NPCs: { Count: number; [i: number]: { Name: string; Dialogue: string } } } | null = {
340
+ Name: "Tavern",
341
+ NPCs: {
342
+ Count: 2,
343
+ 0: { Name: "Barkeep", Dialogue: "Welcome!" },
344
+ 1: { Name: "Bard", Dialogue: "La la la~" },
345
+ },
346
+ }
347
+
348
+ let parentRenders = 0
349
+ const npcRenders: Record<string, number> = {}
350
+
351
+ function NPCView({ npc }: { npc: { Name: string; Dialogue: string } }) {
352
+ const data = useFrameSync(
353
+ () => npc,
354
+ (n) => [n.Name, n.Dialogue]
355
+ )
356
+ npcRenders[data.Name] = (npcRenders[data.Name] || 0) + 1
357
+ return null
358
+ }
359
+
360
+ function PlaceUI() {
361
+ const place = useFrameSync(
362
+ () => currentPlace,
363
+ (p) => [p?.Name, p?.NPCs?.Count]
364
+ )
365
+ parentRenders++
366
+
367
+ if (!place) return null
368
+
369
+ return (
370
+ <>
371
+ {toArray<{ Name: string; Dialogue: string }>(place.NPCs).map((npc, i) => (
372
+ <NPCView key={npc.Name} npc={npc} />
373
+ ))}
374
+ </>
375
+ )
376
+ }
377
+
378
+ render(<PlaceUI />, container as any)
379
+ await flushMicrotasks()
380
+ await advanceFrame()
381
+
382
+ expect(parentRenders).toBeGreaterThanOrEqual(1)
383
+ expect(npcRenders["Barkeep"]).toBeGreaterThanOrEqual(1)
384
+ expect(npcRenders["Bard"]).toBeGreaterThanOrEqual(1)
385
+
386
+ const parentAfterInit = parentRenders
387
+ const barkeepAfter = npcRenders["Barkeep"]
388
+
389
+ // Change Barkeep's dialogue (NPC property change)
390
+ currentPlace!.NPCs[0].Dialogue = "What'll ya have?"
391
+ await advanceFrame()
392
+
393
+ // Parent should NOT re-render (Name and NPC Count unchanged)
394
+ expect(parentRenders).toBe(parentAfterInit)
395
+ // Only Barkeep should re-render
396
+ expect(npcRenders["Barkeep"]).toBeGreaterThan(barkeepAfter)
397
+
398
+ const parentAfterDialogue = parentRenders
399
+
400
+ // Player leaves the tavern (set to null)
401
+ currentPlace = null
402
+ await advanceFrame()
403
+
404
+ // Parent should re-render (place changed to null)
405
+ expect(parentRenders).toBeGreaterThan(parentAfterDialogue)
406
+ })
407
+
408
+ it("multiple property changes in one frame only cause one re-render", async () => {
409
+ const item = createMockItem(1, "Sword", 100)
410
+
411
+ let renderCount = 0
412
+
413
+ function ItemView() {
414
+ const data = useFrameSync(
415
+ () => item,
416
+ (i) => [i.Name, i.Durability, i.StackCount, i.Version]
417
+ )
418
+ renderCount++
419
+ return null
420
+ }
421
+
422
+ render(<ItemView />, container as any)
423
+ await flushMicrotasks()
424
+ await advanceFrame()
425
+ const afterInit = renderCount
426
+
427
+ // Change multiple properties at once (between frames)
428
+ item.Name = "Broken Sword"
429
+ item.Durability = 0
430
+ item.Version = 2
431
+ await advanceFrame()
432
+
433
+ // Should only re-render once for all changes in the same frame
434
+ expect(renderCount).toBe(afterInit + 1)
435
+ })
436
+ })
@@ -20,8 +20,9 @@ import {
20
20
  Slider,
21
21
  ScrollView,
22
22
  Image,
23
+ clearImageCache,
23
24
  } from '../components';
24
- import { MockVisualElement, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI } from './mocks';
25
+ import { MockVisualElement, MockTexture2D, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI, mockFileSystem } from './mocks';
25
26
 
26
27
  // Helper to extract value from style (handles both raw values and MockLength/MockColor)
27
28
  function getStyleValue(style: unknown): unknown {
@@ -58,7 +59,9 @@ describe('components', () => {
58
59
  const el = container.children[0] as MockVisualElement;
59
60
  expect(getStyleValue(el.style.width)).toBe(200);
60
61
  expect(getStyleValue(el.style.height)).toBe(100);
61
- expect(el.style.flexDirection).toBe('row');
62
+ // flexDirection 'row' is converted to the Unity enum value FlexDirection.Row
63
+ const FlexDirection = (globalThis as any).CS.UnityEngine.UIElements.FlexDirection;
64
+ expect(el.style.flexDirection).toBe(FlexDirection.Row);
62
65
  expect(getStyleValue(el.style.paddingTop)).toBe(10);
63
66
  });
64
67
 
@@ -363,6 +366,83 @@ describe('components', () => {
363
366
  expect(getStyleValue(el.style.width)).toBe(100);
364
367
  expect(getStyleValue(el.style.height)).toBe(100);
365
368
  });
369
+
370
+ it('loads texture when src is provided', async () => {
371
+ mockFileSystem.set("/project/App/assets/images/logo.png", [0x89, 0x50, 0x4e, 0x47]);
372
+ (globalThis as any).__workingDir = "/project/App";
373
+
374
+ const container = createMockContainer();
375
+ render(<Image src="images/logo.png" style={{ width: 64, height: 64 }} />, container as any);
376
+ await flushMicrotasks();
377
+
378
+ const el = container.children[0] as any;
379
+ expect(el.image).toBeInstanceOf(MockTexture2D);
380
+ expect((el.image as MockTexture2D)._loaded).toBe(true);
381
+ });
382
+
383
+ it('returns null for missing file with console.error', async () => {
384
+ (globalThis as any).__workingDir = "/project/App";
385
+
386
+ const container = createMockContainer();
387
+ render(<Image src="images/missing.png" />, container as any);
388
+ await flushMicrotasks();
389
+
390
+ const el = container.children[0] as any;
391
+ expect(el.image).toBeNull();
392
+ expect(console.error).toHaveBeenCalledWith(
393
+ expect.stringContaining("Image src not found: images/missing.png")
394
+ );
395
+ });
396
+
397
+ it('caches textures across renders', async () => {
398
+ mockFileSystem.set("/project/App/assets/photos/hero.png", [0x89, 0x50]);
399
+ (globalThis as any).__workingDir = "/project/App";
400
+ const cs = (globalThis as any).CS;
401
+ const readSpy = vi.spyOn(cs.System.IO.File, 'ReadAllBytes');
402
+
403
+ const container = createMockContainer();
404
+ render(<Image src="photos/hero.png" />, container as any);
405
+ await flushMicrotasks();
406
+
407
+ // Re-render with same src
408
+ render(<Image src="photos/hero.png" />, container as any);
409
+ await flushMicrotasks();
410
+
411
+ expect(readSpy).toHaveBeenCalledTimes(1);
412
+ });
413
+
414
+ it('src takes precedence over image prop', async () => {
415
+ mockFileSystem.set("/project/App/assets/a.png", [0x89]);
416
+ (globalThis as any).__workingDir = "/project/App";
417
+ const manualTex = { manual: true };
418
+
419
+ const container = createMockContainer();
420
+ render(<Image src="a.png" image={manualTex} />, container as any);
421
+ await flushMicrotasks();
422
+
423
+ const el = container.children[0] as any;
424
+ expect(el.image).toBeInstanceOf(MockTexture2D);
425
+ });
426
+
427
+ it('clearImageCache resets the cache', async () => {
428
+ mockFileSystem.set("/project/App/assets/b.png", [0x89]);
429
+ (globalThis as any).__workingDir = "/project/App";
430
+ const cs = (globalThis as any).CS;
431
+ const readSpy = vi.spyOn(cs.System.IO.File, 'ReadAllBytes');
432
+
433
+ const container1 = createMockContainer();
434
+ render(<Image src="b.png" />, container1 as any);
435
+ await flushMicrotasks();
436
+
437
+ clearImageCache();
438
+
439
+ // Render in a new container to force a fresh component mount
440
+ const container2 = createMockContainer();
441
+ render(<Image src="b.png" />, container2 as any);
442
+ await flushMicrotasks();
443
+
444
+ expect(readSpy).toHaveBeenCalledTimes(2);
445
+ });
366
446
  });
367
447
 
368
448
  describe('event handler mapping', () => {
@@ -663,21 +663,23 @@ describe('host-config', () => {
663
663
  });
664
664
 
665
665
  describe('visibility', () => {
666
- it('hideInstance sets display to none', () => {
666
+ it('hideInstance sets display to DisplayStyle.None', () => {
667
667
  const instance = createInstance('ojs-view', {});
668
668
 
669
669
  hideInstance(instance);
670
670
 
671
- expect(instance.element.style.display).toBe('none');
671
+ const DisplayStyle = (globalThis as any).CS.UnityEngine.UIElements.DisplayStyle;
672
+ expect(instance.element.style.display).toBe(DisplayStyle.None);
672
673
  });
673
674
 
674
- it('unhideInstance clears display', () => {
675
+ it('unhideInstance sets display to DisplayStyle.Flex', () => {
675
676
  const instance = createInstance('ojs-view', {});
676
- instance.element.style.display = 'none';
677
+ const DisplayStyle = (globalThis as any).CS.UnityEngine.UIElements.DisplayStyle;
678
+ instance.element.style.display = DisplayStyle.None;
677
679
 
678
680
  unhideInstance(instance, {});
679
681
 
680
- expect(instance.element.style.display).toBe('');
682
+ expect(instance.element.style.display).toBe(DisplayStyle.Flex);
681
683
  });
682
684
  });
683
685
 
@@ -223,14 +223,64 @@ export class MockImage extends MockVisualElement {
223
223
  }
224
224
  }
225
225
 
226
+ /**
227
+ * Mock Texture2D for image loading tests
228
+ */
229
+ export class MockTexture2D {
230
+ width: number
231
+ height: number
232
+ filterMode: number = 0
233
+ _loaded = false
234
+
235
+ constructor(w: number, h: number) {
236
+ this.width = w
237
+ this.height = h
238
+ }
239
+
240
+ LoadImage(_bytes: any): boolean {
241
+ this._loaded = true
242
+ return true
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Mock file system for image loading tests.
248
+ * Tests can add entries to control which files "exist" and what bytes they contain.
249
+ */
250
+ export const mockFileSystem = new Map<string, number[]>()
251
+
226
252
  /**
227
253
  * Create the mock CS global object that mirrors QuickJSBootstrap.js proxy
254
+ *
255
+ * Enum values match Unity's actual enum definitions so that tests
256
+ * verify the real mapping behavior (CSS string -> Unity enum number).
228
257
  */
229
258
  export function createMockCS() {
230
259
  return {
260
+ System: {
261
+ IO: {
262
+ Path: {
263
+ Combine: (...parts: string[]) => parts.join("/"),
264
+ GetDirectoryName: (p: string) => p.substring(0, p.lastIndexOf("/")),
265
+ },
266
+ File: {
267
+ Exists: (path: string) => mockFileSystem.has(path),
268
+ ReadAllBytes: (path: string) => mockFileSystem.get(path) || [],
269
+ },
270
+ },
271
+ },
231
272
  UnityEngine: {
232
273
  // Core types
233
274
  Color: MockColor,
275
+ Rect: class { constructor(public x: number, public y: number, public width: number, public height: number) {} },
276
+ ScaleMode: { StretchToFill: 0, ScaleAndCrop: 1, ScaleToFit: 2 },
277
+ Application: {
278
+ isEditor: true,
279
+ dataPath: "/project/Assets",
280
+ streamingAssetsPath: "/project/Assets/StreamingAssets",
281
+ },
282
+ Texture2D: MockTexture2D,
283
+ FilterMode: { Point: 0, Bilinear: 1, Trilinear: 2 },
234
284
  // UI Elements
235
285
  UIElements: {
236
286
  VisualElement: MockVisualElement,
@@ -246,6 +296,39 @@ export function createMockCS() {
246
296
  Length: MockLength,
247
297
  LengthUnit: MockLengthUnit,
248
298
  StyleKeyword: MockStyleKeyword,
299
+ // Enums (values match Unity's actual enum definitions)
300
+ FlexDirection: { Column: 0, ColumnReverse: 1, Row: 2, RowReverse: 3 },
301
+ Wrap: { NoWrap: 0, Wrap: 1, WrapReverse: 2 },
302
+ Align: { Auto: 0, FlexStart: 1, Center: 2, FlexEnd: 3, Stretch: 4 },
303
+ Justify: { FlexStart: 0, Center: 1, FlexEnd: 2, SpaceBetween: 3, SpaceAround: 4 },
304
+ Position: { Relative: 0, Absolute: 1 },
305
+ Overflow: { Visible: 0, Hidden: 1 },
306
+ DisplayStyle: { Flex: 0, None: 1 },
307
+ Visibility: { Visible: 0, Hidden: 1 },
308
+ WhiteSpace: { Normal: 0, NoWrap: 1 },
309
+ TextOverflow: { Clip: 0, Ellipsis: 1 },
310
+ TextOverflowPosition: { End: 0, Start: 1, Middle: 2 },
311
+ OverflowClipBox: { PaddingBox: 0, ContentBox: 1 },
312
+ PickingMode: { Position: 0, Ignore: 1 },
313
+ SliderDirection: { Horizontal: 0, Vertical: 1 },
314
+ // ScrollView enums
315
+ ScrollViewMode: { Vertical: 0, Horizontal: 1, VerticalAndHorizontal: 2 },
316
+ ScrollerVisibility: { Auto: 0, AlwaysVisible: 1, Hidden: 2 },
317
+ TouchScrollBehavior: { Unrestricted: 0, Elastic: 1, Clamped: 2 },
318
+ NestedInteractionKind: { Default: 0, StopScrolling: 1, ForwardScrolling: 2 },
319
+ // ListView enums
320
+ SelectionType: { None: 0, Single: 1, Multiple: 2 },
321
+ ListViewReorderMode: { Simple: 0, Animated: 1 },
322
+ AlternatingRowBackground: { None: 0, ContentOnly: 1, All: 2 },
323
+ CollectionVirtualizationMethod: { FixedHeight: 0, DynamicHeight: 1 },
324
+ },
325
+ },
326
+ OneJS: {
327
+ GPU: {
328
+ GPUBridge: {
329
+ SetElementBackgroundImage: () => {},
330
+ ClearElementBackgroundImage: () => {},
331
+ },
249
332
  },
250
333
  },
251
334
  };
@@ -270,6 +353,7 @@ export function findElementByHandle(handle: number): MockVisualElement | undefin
270
353
  */
271
354
  export function resetAllMocks(): void {
272
355
  createdElements = [];
356
+ mockFileSystem.clear();
273
357
  }
274
358
 
275
359
  /**
@@ -323,5 +407,7 @@ export function getEventAPI() {
323
407
  addEventListener: ReturnType<typeof import('vitest').vi.fn>;
324
408
  removeEventListener: ReturnType<typeof import('vitest').vi.fn>;
325
409
  removeAllEventListeners: ReturnType<typeof import('vitest').vi.fn>;
410
+ setParent: ReturnType<typeof import('vitest').vi.fn>;
411
+ removeParent: ReturnType<typeof import('vitest').vi.fn>;
326
412
  };
327
413
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Pre-setup - Defines globals that must exist before any module imports.
3
+ *
4
+ * This runs before setup.ts to ensure useExtensions and CS are available when
5
+ * components.tsx is first imported (it calls useExtensions at module level).
6
+ * setup.ts replaces CS with a full mock in beforeEach.
7
+ */
8
+
9
+ import { createMockCS } from "./mocks";
10
+
11
+ // No-op useExtensions for test environment (extension methods are mocked directly on types)
12
+ (globalThis as any).useExtensions = () => {};
13
+
14
+ // Minimal CS mock so module-level code can reference CS.UnityEngine.ImageConversion
15
+ (globalThis as any).CS = createMockCS();
@@ -10,16 +10,17 @@
10
10
 
11
11
  import { vi, beforeEach, afterEach } from "vitest";
12
12
  import { createMockCS, resetAllMocks } from "./mocks";
13
+ import { clearImageCache } from "../components";
13
14
 
14
15
  // Extend globalThis type for our mocks
15
16
  declare global {
16
- // eslint-disable-next-line no-var
17
- var CS: ReturnType<typeof createMockCS>;
18
17
  // eslint-disable-next-line no-var
19
18
  var __eventAPI: {
20
19
  addEventListener: ReturnType<typeof vi.fn>;
21
20
  removeEventListener: ReturnType<typeof vi.fn>;
22
21
  removeAllEventListeners: ReturnType<typeof vi.fn>;
22
+ setParent: ReturnType<typeof vi.fn>;
23
+ removeParent: ReturnType<typeof vi.fn>;
23
24
  };
24
25
  }
25
26
 
@@ -31,15 +32,18 @@ const originalQueueMicrotask = global.queueMicrotask;
31
32
  // Set up globals before each test
32
33
  beforeEach(() => {
33
34
  resetAllMocks();
35
+ clearImageCache();
34
36
 
35
37
  // Create fresh mock CS global
36
- global.CS = createMockCS();
38
+ (globalThis as any).CS = createMockCS();
37
39
 
38
40
  // Mock event API with spies
39
41
  global.__eventAPI = {
40
42
  addEventListener: vi.fn(),
41
43
  removeEventListener: vi.fn(),
42
44
  removeAllEventListeners: vi.fn(),
45
+ setParent: vi.fn(),
46
+ removeParent: vi.fn(),
43
47
  };
44
48
 
45
49
  // Use real console but spy on it for test assertions
@@ -303,10 +303,12 @@ describe("style-parser", () => {
303
303
  expect(parseStyleValue("flexShrink", 0)).toBe(0)
304
304
  })
305
305
 
306
- it("passes through enum properties unchanged", () => {
307
- expect(parseStyleValue("flexDirection", "row")).toBe("row")
308
- expect(parseStyleValue("display", "none")).toBe("none")
309
- expect(parseStyleValue("position", "absolute")).toBe("absolute")
306
+ it("converts enum properties to Unity enum values", () => {
307
+ const CS = (globalThis as any).CS
308
+ const UIE = CS.UnityEngine.UIElements
309
+ expect(parseStyleValue("flexDirection", "row")).toBe(UIE.FlexDirection.Row)
310
+ expect(parseStyleValue("display", "none")).toBe(UIE.DisplayStyle.None)
311
+ expect(parseStyleValue("position", "absolute")).toBe(UIE.Position.Absolute)
310
312
  })
311
313
 
312
314
  it("passes through unknown properties unchanged", () => {
@@ -1,4 +1,4 @@
1
- import { forwardRef, type ReactElement, type Ref } from 'react';
1
+ import { forwardRef, useMemo, type ReactElement, type Ref } from 'react';
2
2
  import type {
3
3
  ViewProps,
4
4
  TextProps,
@@ -21,6 +21,52 @@ import type {
21
21
  ImageElement,
22
22
  } from './types';
23
23
 
24
+ declare const CS: any
25
+ declare function useExtensions(typeRef: any): void
26
+
27
+ // Register ImageConversion extension methods so tex.LoadImage(bytes) works
28
+ useExtensions(CS.UnityEngine.ImageConversion)
29
+
30
+ // Module-level texture cache shared across all Image instances
31
+ const _textureCache = new Map<string, any>()
32
+
33
+ function _resolveAssetPath(src: string): string {
34
+ const Path = CS.System.IO.Path
35
+ if (CS.UnityEngine.Application.isEditor) {
36
+ const workingDir = typeof (globalThis as any).__workingDir === "string"
37
+ ? (globalThis as any).__workingDir
38
+ : Path.Combine(Path.GetDirectoryName(CS.UnityEngine.Application.dataPath), "App")
39
+ return Path.Combine(workingDir, "assets", src)
40
+ }
41
+ return Path.Combine(CS.UnityEngine.Application.streamingAssetsPath, "onejs", "assets", src)
42
+ }
43
+
44
+ function _loadTexture(src: string): any | null {
45
+ const cached = _textureCache.get(src)
46
+ if (cached) return cached
47
+
48
+ const fullPath = _resolveAssetPath(src)
49
+ if (!CS.System.IO.File.Exists(fullPath)) {
50
+ console.error(`Image src not found: ${src} (resolved to ${fullPath})`)
51
+ return null
52
+ }
53
+
54
+ const bytes = CS.System.IO.File.ReadAllBytes(fullPath)
55
+ const tex = new CS.UnityEngine.Texture2D(2, 2)
56
+ tex.LoadImage(bytes)
57
+ tex.filterMode = CS.UnityEngine.FilterMode.Bilinear
58
+ _textureCache.set(src, tex)
59
+ return tex
60
+ }
61
+
62
+ /**
63
+ * Clear the Image component's texture cache.
64
+ * Call this if you need to force-reload images (e.g., after replacing files on disk).
65
+ */
66
+ export function clearImageCache(): void {
67
+ _textureCache.clear()
68
+ }
69
+
24
70
  // Props with ref support for intrinsic elements
25
71
  type WithRef<Props, Element> = Props & { ref?: Ref<Element> };
26
72
 
@@ -105,8 +151,12 @@ export const ScrollView = forwardRef<ScrollViewElement, ScrollViewProps>((props,
105
151
  });
106
152
  ScrollView.displayName = 'ScrollView';
107
153
 
108
- export const Image = forwardRef<ImageElement, ImageProps>((props, ref) => {
109
- return <ojs-image ref={ref} {...props} />;
154
+ export const Image = forwardRef<ImageElement, ImageProps>(({ src, image, ...rest }, ref) => {
155
+ const resolvedImage = useMemo(() => {
156
+ if (src) return _loadTexture(src)
157
+ return image
158
+ }, [src, image])
159
+ return <ojs-image ref={ref} image={resolvedImage} {...rest} />;
110
160
  });
111
161
  Image.displayName = 'Image';
112
162
 
package/src/hooks.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { useState, useEffect, useRef, useReducer } from "react"
2
2
 
3
+ // QuickJS environment declarations
4
+ declare function requestAnimationFrame(callback: (time: number) => void): number;
5
+ declare function cancelAnimationFrame(id: number): void;
6
+
3
7
  /**
4
8
  * Syncs a value from C# (or any external source) to React state, checking every frame.
5
9
  *
@@ -56,7 +56,10 @@ declare const CS: {
56
56
  CollectionVirtualizationMethod: CSEnum;
57
57
  DisplayStyle: CSEnum;
58
58
  PickingMode: CSEnum;
59
+ SliderDirection: CSEnum;
59
60
  };
61
+ ScaleMode: CSEnum;
62
+ Rect: new (...args: any[]) => any;
60
63
  };
61
64
  OneJS: {
62
65
  GPU: {
@@ -578,9 +581,10 @@ function removeMergedTextChild(parentInstance: Instance, child: Instance) {
578
581
 
579
582
  // Apply common props (text, value, label)
580
583
  function applyCommonProps(element: CSObject, props: Record<string, unknown>) {
581
- if (props.text !== undefined) (element as { text: string }).text = props.text as string;
582
- if (props.value !== undefined) (element as { value: unknown }).value = props.value;
583
- if (props.label !== undefined) (element as { label: string }).label = props.label as string;
584
+ const el = element as any;
585
+ if (props.text !== undefined) el.text = props.text as string;
586
+ if (props.value !== undefined) el.value = props.value;
587
+ if (props.label !== undefined) el.label = props.label as string;
584
588
  }
585
589
 
586
590
  // Helper to set enum prop if defined
@@ -599,106 +603,62 @@ function setValueProp<T>(target: T, key: keyof T, props: Record<string, unknown>
599
603
 
600
604
  // Apply TextField-specific properties
601
605
  function applyTextFieldProps(element: CSObject, props: Record<string, unknown>) {
602
- // Map readOnly prop to isReadOnly property
603
- if (props.readOnly !== undefined) {
604
- (element as { isReadOnly: boolean }).isReadOnly = props.readOnly as boolean;
605
- }
606
- if (props.multiline !== undefined) {
607
- (element as { multiline: boolean }).multiline = props.multiline as boolean;
608
- }
609
- if (props.maxLength !== undefined) {
610
- (element as { maxLength: number }).maxLength = props.maxLength as number;
611
- }
612
- if (props.isPasswordField !== undefined) {
613
- (element as { isPasswordField: boolean }).isPasswordField = props.isPasswordField as boolean;
614
- }
615
- if (props.maskChar !== undefined) {
616
- (element as { maskChar: string }).maskChar = (props.maskChar as string).charAt(0);
617
- }
618
- if (props.isDelayed !== undefined) {
619
- (element as { isDelayed: boolean }).isDelayed = props.isDelayed as boolean;
620
- }
621
- if (props.selectAllOnFocus !== undefined) {
622
- (element as { selectAllOnFocus: boolean }).selectAllOnFocus = props.selectAllOnFocus as boolean;
623
- }
624
- if (props.selectAllOnMouseUp !== undefined) {
625
- (element as { selectAllOnMouseUp: boolean }).selectAllOnMouseUp = props.selectAllOnMouseUp as boolean;
626
- }
627
- if (props.hideMobileInput !== undefined) {
628
- (element as { hideMobileInput: boolean }).hideMobileInput = props.hideMobileInput as boolean;
629
- }
630
- if (props.autoCorrection !== undefined) {
631
- (element as { autoCorrection: boolean }).autoCorrection = props.autoCorrection as boolean;
632
- }
606
+ const el = element as any;
607
+ if (props.readOnly !== undefined) el.isReadOnly = props.readOnly;
608
+ if (props.multiline !== undefined) el.multiline = props.multiline;
609
+ if (props.maxLength !== undefined) el.maxLength = props.maxLength;
610
+ if (props.isPasswordField !== undefined) el.isPasswordField = props.isPasswordField;
611
+ if (props.maskChar !== undefined) el.maskChar = (props.maskChar as string).charAt(0);
612
+ if (props.isDelayed !== undefined) el.isDelayed = props.isDelayed;
613
+ if (props.selectAllOnFocus !== undefined) el.selectAllOnFocus = props.selectAllOnFocus;
614
+ if (props.selectAllOnMouseUp !== undefined) el.selectAllOnMouseUp = props.selectAllOnMouseUp;
615
+ if (props.hideMobileInput !== undefined) el.hideMobileInput = props.hideMobileInput;
616
+ if (props.autoCorrection !== undefined) el.autoCorrection = props.autoCorrection;
633
617
  // Note: placeholder is handled differently in Unity - it's set via the textEdition interface
634
618
  // For now we skip it as it requires more complex handling
635
619
  }
636
620
 
637
621
  // Apply Slider-specific properties
638
622
  function applySliderProps(element: CSObject, props: Record<string, unknown>) {
639
- if (props.lowValue !== undefined) {
640
- (element as { lowValue: number }).lowValue = props.lowValue as number;
641
- }
642
- if (props.highValue !== undefined) {
643
- (element as { highValue: number }).highValue = props.highValue as number;
644
- }
645
- if (props.showInputField !== undefined) {
646
- (element as { showInputField: boolean }).showInputField = props.showInputField as boolean;
647
- }
648
- if (props.inverted !== undefined) {
649
- (element as { inverted: boolean }).inverted = props.inverted as boolean;
650
- }
651
- if (props.pageSize !== undefined) {
652
- (element as { pageSize: number }).pageSize = props.pageSize as number;
653
- }
654
- if (props.fill !== undefined) {
655
- (element as { fill: boolean }).fill = props.fill as boolean;
656
- }
623
+ const el = element as any;
624
+ if (props.lowValue !== undefined) el.lowValue = props.lowValue;
625
+ if (props.highValue !== undefined) el.highValue = props.highValue;
626
+ if (props.showInputField !== undefined) el.showInputField = props.showInputField;
627
+ if (props.inverted !== undefined) el.inverted = props.inverted;
628
+ if (props.pageSize !== undefined) el.pageSize = props.pageSize;
629
+ if (props.fill !== undefined) el.fill = props.fill;
657
630
  if (props.direction !== undefined) {
658
- const UIE = CS.UnityEngine.UIElements;
659
- (element as { direction: unknown }).direction = UIE.SliderDirection[props.direction as string];
631
+ el.direction = CS.UnityEngine.UIElements.SliderDirection[props.direction as string];
660
632
  }
661
633
  }
662
634
 
663
635
  // Apply Toggle-specific properties
664
636
  function applyToggleProps(element: CSObject, props: Record<string, unknown>) {
665
- if (props.text !== undefined) {
666
- (element as { text: string }).text = props.text as string;
667
- }
668
- if (props.toggleOnLabelClick !== undefined) {
669
- (element as { toggleOnLabelClick: boolean }).toggleOnLabelClick = props.toggleOnLabelClick as boolean;
670
- }
637
+ const el = element as any;
638
+ if (props.text !== undefined) el.text = props.text;
639
+ if (props.toggleOnLabelClick !== undefined) el.toggleOnLabelClick = props.toggleOnLabelClick;
671
640
  }
672
641
 
673
642
  // Apply Image-specific properties
674
643
  function applyImageProps(element: CSObject, props: Record<string, unknown>) {
675
- if (props.image !== undefined) {
676
- (element as { image: unknown }).image = props.image;
677
- }
678
- if (props.sprite !== undefined) {
679
- (element as { sprite: unknown }).sprite = props.sprite;
680
- }
681
- if (props.vectorImage !== undefined) {
682
- (element as { vectorImage: unknown }).vectorImage = props.vectorImage;
683
- }
644
+ const el = element as any;
645
+ if (props.image !== undefined) el.image = props.image;
646
+ if (props.sprite !== undefined) el.sprite = props.sprite;
647
+ if (props.vectorImage !== undefined) el.vectorImage = props.vectorImage;
684
648
  if (props.scaleMode !== undefined) {
685
- const scaleMode = CS.UnityEngine.ScaleMode[props.scaleMode as string];
686
- (element as { scaleMode: unknown }).scaleMode = scaleMode;
649
+ el.scaleMode = CS.UnityEngine.ScaleMode[props.scaleMode as string];
687
650
  }
688
651
  if (props.tintColor !== undefined) {
689
- // Parse color string to Unity Color
690
652
  const color = parseColor(props.tintColor as string);
691
- if (color) {
692
- (element as { tintColor: unknown }).tintColor = color;
693
- }
653
+ if (color) el.tintColor = color;
694
654
  }
695
655
  if (props.sourceRect !== undefined) {
696
656
  const rect = props.sourceRect as { x: number; y: number; width: number; height: number };
697
- (element as { sourceRect: unknown }).sourceRect = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
657
+ el.sourceRect = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
698
658
  }
699
659
  if (props.uv !== undefined) {
700
660
  const rect = props.uv as { x: number; y: number; width: number; height: number };
701
- (element as { uv: unknown }).uv = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
661
+ el.uv = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
702
662
  }
703
663
  }
704
664
 
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  ScrollView,
11
11
  Image,
12
12
  ListView,
13
+ clearImageCache,
13
14
  } from './components';
14
15
 
15
16
  // Renderer
@@ -9,11 +9,21 @@
9
9
  declare const CS: {
10
10
  UnityEngine: {
11
11
  Color: new (r: number, g: number, b: number, a: number) => CSColor;
12
+ Vector3: new (x: number, y: number, z: number) => any;
12
13
  FontStyle: Record<string, number>;
13
14
  UIElements: {
14
15
  Length: new (value: number, unit?: number) => CSLength;
15
16
  LengthUnit: { Pixel: number; Percent: number };
16
17
  StyleKeyword: { Auto: number; None: number; Initial: number };
18
+ Angle: { Degrees: (v: number) => any; Radians: (v: number) => any; Turns: (v: number) => any; Gradians: (v: number) => any };
19
+ Translate: new (x: any, y: any) => any;
20
+ Rotate: new (angle: any) => any;
21
+ Scale: new (v: any) => any;
22
+ TransformOrigin: new (x: any, y: any) => any;
23
+ StyleTranslate: new (v: any) => any;
24
+ StyleRotate: new (v: any) => any;
25
+ StyleScale: new (v: any) => any;
26
+ StyleTransformOrigin: new (v: any) => any;
17
27
  // Enums for style properties
18
28
  FlexDirection: Record<string, number>;
19
29
  Wrap: Record<string, number>;
@@ -357,6 +367,130 @@ export function parseColor(value: string): CSColor | null {
357
367
  return null
358
368
  }
359
369
 
370
+ /**
371
+ * Parse a length value for transform properties (no keyword support)
372
+ * number → Length(n, Pixel), "50%" → Length(50, Percent), "10px" → Length(10, Pixel)
373
+ */
374
+ function parseLengthForTransform(value: number | string): CSLength | null {
375
+ if (typeof value === "number") {
376
+ return new CS.UnityEngine.UIElements.Length(value, CS.UnityEngine.UIElements.LengthUnit.Pixel)
377
+ }
378
+ if (typeof value === "string") {
379
+ const match = value.trim().match(/^(-?[\d.]+)(px|%)?$/)
380
+ if (match) {
381
+ const num = parseFloat(match[1])
382
+ if (isNaN(num)) return null
383
+ const unit = match[2] === "%"
384
+ ? CS.UnityEngine.UIElements.LengthUnit.Percent
385
+ : CS.UnityEngine.UIElements.LengthUnit.Pixel
386
+ return new CS.UnityEngine.UIElements.Length(num, unit)
387
+ }
388
+ }
389
+ return null
390
+ }
391
+
392
+ /**
393
+ * Parse an angle value
394
+ * number → Angle.Degrees(n), string → parse unit suffix (deg|rad|turn|grad)
395
+ */
396
+ function parseAngle(value: number | string): any | null {
397
+ const Angle = CS.UnityEngine.UIElements.Angle
398
+ if (typeof value === "number") {
399
+ return Angle.Degrees(value)
400
+ }
401
+ if (typeof value === "string") {
402
+ const match = value.trim().match(/^(-?[\d.]+)(deg|rad|turn|grad)?$/)
403
+ if (match) {
404
+ const num = parseFloat(match[1])
405
+ if (isNaN(num)) return null
406
+ const unit = match[2] || "deg"
407
+ if (unit === "rad") return Angle.Radians(num)
408
+ if (unit === "turn") return Angle.Turns(num)
409
+ if (unit === "grad") return Angle.Gradians(num)
410
+ return Angle.Degrees(num)
411
+ }
412
+ }
413
+ return null
414
+ }
415
+
416
+ /**
417
+ * Parse translate style: [x, y] array or C# Translate pass-through
418
+ */
419
+ function parseTranslateStyle(value: unknown): unknown {
420
+ if (Array.isArray(value)) {
421
+ const x = parseLengthForTransform(value[0])
422
+ const y = parseLengthForTransform(value[1])
423
+ if (x !== null && y !== null) {
424
+ return new CS.UnityEngine.UIElements.StyleTranslate(
425
+ new CS.UnityEngine.UIElements.Translate(x, y)
426
+ )
427
+ }
428
+ }
429
+ if (typeof value === "object" && value !== null) {
430
+ try { return new CS.UnityEngine.UIElements.StyleTranslate(value) } catch { return value }
431
+ }
432
+ return value
433
+ }
434
+
435
+ /**
436
+ * Parse rotate style: number (degrees), string with unit, or C# Rotate pass-through
437
+ */
438
+ function parseRotateStyle(value: unknown): unknown {
439
+ if (typeof value === "number" || typeof value === "string") {
440
+ const angle = parseAngle(value)
441
+ if (angle !== null) {
442
+ return new CS.UnityEngine.UIElements.StyleRotate(
443
+ new CS.UnityEngine.UIElements.Rotate(angle)
444
+ )
445
+ }
446
+ }
447
+ if (typeof value === "object" && value !== null) {
448
+ try { return new CS.UnityEngine.UIElements.StyleRotate(value) } catch { return value }
449
+ }
450
+ return value
451
+ }
452
+
453
+ /**
454
+ * Parse scale style: number (uniform), [x, y] array, or C# Scale pass-through
455
+ */
456
+ function parseScaleStyle(value: unknown): unknown {
457
+ if (typeof value === "number") {
458
+ return new CS.UnityEngine.UIElements.StyleScale(
459
+ new CS.UnityEngine.UIElements.Scale(new CS.UnityEngine.Vector3(value, value, 1))
460
+ )
461
+ }
462
+ if (Array.isArray(value)) {
463
+ const x = typeof value[0] === "number" ? value[0] : 1
464
+ const y = typeof value[1] === "number" ? value[1] : 1
465
+ return new CS.UnityEngine.UIElements.StyleScale(
466
+ new CS.UnityEngine.UIElements.Scale(new CS.UnityEngine.Vector3(x, y, 1))
467
+ )
468
+ }
469
+ if (typeof value === "object" && value !== null) {
470
+ try { return new CS.UnityEngine.UIElements.StyleScale(value) } catch { return value }
471
+ }
472
+ return value
473
+ }
474
+
475
+ /**
476
+ * Parse transformOrigin style: [x, y] array or C# TransformOrigin pass-through
477
+ */
478
+ function parseTransformOriginStyle(value: unknown): unknown {
479
+ if (Array.isArray(value)) {
480
+ const x = parseLengthForTransform(value[0])
481
+ const y = parseLengthForTransform(value[1])
482
+ if (x !== null && y !== null) {
483
+ return new CS.UnityEngine.UIElements.StyleTransformOrigin(
484
+ new CS.UnityEngine.UIElements.TransformOrigin(x, y)
485
+ )
486
+ }
487
+ }
488
+ if (typeof value === "object" && value !== null) {
489
+ try { return new CS.UnityEngine.UIElements.StyleTransformOrigin(value) } catch { return value }
490
+ }
491
+ return value
492
+ }
493
+
360
494
  /**
361
495
  * Parse a style value based on the property name
362
496
  * @param key - Style property name (e.g., "width", "backgroundColor")
@@ -399,6 +533,12 @@ export function parseStyleValue(key: string, value: unknown): unknown {
399
533
  // Fall through if parsing failed
400
534
  }
401
535
 
536
+ // Transform properties
537
+ if (key === "translate") return parseTranslateStyle(value)
538
+ if (key === "rotate") return parseRotateStyle(value)
539
+ if (key === "scale") return parseScaleStyle(value)
540
+ if (key === "transformOrigin") return parseTransformOriginStyle(value)
541
+
402
542
  // Unknown property - pass through unchanged
403
543
  return value
404
544
  }
package/src/types.ts CHANGED
@@ -167,13 +167,31 @@ export interface ViewStyle {
167
167
  unitySliceScale?: number;
168
168
 
169
169
  // Transform
170
- /** Rotation transform. Pass a C# Rotate struct. */
170
+ /**
171
+ * Rotation transform.
172
+ * - number: degrees (e.g., 45)
173
+ * - string: with unit (e.g., "0.5turn", "1.57rad", "45deg", "50grad")
174
+ * - C# Rotate struct: pass-through (auto-wrapped in StyleRotate)
175
+ */
171
176
  rotate?: any;
172
- /** Scale transform. Pass a C# Scale struct. */
177
+ /**
178
+ * Scale transform.
179
+ * - number: uniform scale (e.g., 1.5)
180
+ * - [x, y]: non-uniform scale (e.g., [1.5, 2])
181
+ * - C# Scale struct: pass-through (auto-wrapped in StyleScale)
182
+ */
173
183
  scale?: any;
174
- /** Translation transform. Pass a C# Translate struct. */
184
+ /**
185
+ * Translation transform.
186
+ * - [x, y]: numbers are px, strings parsed (e.g., [10, 20] or ["50%", 10])
187
+ * - C# Translate struct: pass-through (auto-wrapped in StyleTranslate)
188
+ */
175
189
  translate?: any;
176
- /** Transform origin point. Pass a C# TransformOrigin struct. */
190
+ /**
191
+ * Transform origin point.
192
+ * - [x, y]: numbers are px, strings parsed (e.g., ["50%", "50%"])
193
+ * - C# TransformOrigin struct: pass-through (auto-wrapped in StyleTransformOrigin)
194
+ */
177
195
  transformOrigin?: any;
178
196
 
179
197
  // Transition
@@ -510,7 +528,9 @@ export interface ScrollViewProps extends BaseProps {
510
528
  }
511
529
 
512
530
  export interface ImageProps extends BaseProps {
513
- /** Image source - can be a Texture2D, Sprite, or path string */
531
+ /** Path to image asset relative to the assets/ folder (convenience prop) */
532
+ src?: string;
533
+ /** Pre-loaded Texture2D object (use when you loaded the texture yourself) */
514
534
  image?: object;
515
535
  /** Sprite to display (alternative to image) */
516
536
  sprite?: object;
@@ -636,7 +656,13 @@ export interface ScrollViewElement extends VisualElement {
636
656
  }
637
657
 
638
658
  export interface ImageElement extends VisualElement {
639
- // Image-specific properties handled via style.backgroundImage
659
+ image: any;
660
+ sprite: any;
661
+ vectorImage: any;
662
+ scaleMode: number;
663
+ tintColor: any;
664
+ sourceRect: any;
665
+ uv: any;
640
666
  }
641
667
 
642
668
  // ListView uses Unity's virtualization callbacks directly