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 +1 -1
- package/src/__tests__/collection-sync.test.tsx +436 -0
- package/src/__tests__/components.test.tsx +82 -2
- package/src/__tests__/host-config.test.ts +7 -5
- package/src/__tests__/mocks.ts +86 -0
- package/src/__tests__/pre-setup.ts +15 -0
- package/src/__tests__/setup.ts +7 -3
- package/src/__tests__/style-parser.test.ts +6 -4
- package/src/components.tsx +53 -3
- package/src/hooks.ts +4 -0
- package/src/host-config.ts +37 -77
- package/src/index.ts +1 -0
- package/src/style-parser.ts +140 -0
- package/src/types.ts +32 -6
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
666
|
+
it('hideInstance sets display to DisplayStyle.None', () => {
|
|
667
667
|
const instance = createInstance('ojs-view', {});
|
|
668
668
|
|
|
669
669
|
hideInstance(instance);
|
|
670
670
|
|
|
671
|
-
|
|
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
|
|
675
|
+
it('unhideInstance sets display to DisplayStyle.Flex', () => {
|
|
675
676
|
const instance = createInstance('ojs-view', {});
|
|
676
|
-
|
|
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
|
|
package/src/__tests__/mocks.ts
CHANGED
|
@@ -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();
|
package/src/__tests__/setup.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
expect(parseStyleValue("
|
|
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", () => {
|
package/src/components.tsx
CHANGED
|
@@ -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>((
|
|
109
|
-
|
|
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
|
*
|
package/src/host-config.ts
CHANGED
|
@@ -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
|
-
|
|
582
|
-
if (props.
|
|
583
|
-
if (props.
|
|
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
|
-
|
|
603
|
-
if (props.readOnly !== undefined)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
if (props.
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
if (props.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
if (props.
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
if (props.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
if (props.
|
|
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
|
-
|
|
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
|
-
|
|
666
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (props.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/style-parser.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|