onejs-react 0.1.0 → 0.1.2

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.
@@ -1,6 +1,9 @@
1
1
  import type {HostConfig} from 'react-reconciler';
2
- import type {BaseProps, ViewStyle} from './types';
3
- import {parseStyleValue} from './style-parser';
2
+ import type {BaseProps, ViewStyle, VisualElement, GenerateVisualContentCallback} from './types';
3
+ import {parseStyleValue, parseColor} from './style-parser';
4
+
5
+ // CSObject is an alias for VisualElement - they represent the same C# objects
6
+ type CSObject = VisualElement;
4
7
 
5
8
  // Global declarations for QuickJS environment
6
9
  declare function setTimeout(callback: () => void, ms?: number): number;
@@ -32,6 +35,7 @@ declare const CS: {
32
35
  UnityEngine: {
33
36
  UIElements: {
34
37
  VisualElement: new () => CSObject;
38
+ TextElement: new () => CSObject;
35
39
  Label: new () => CSObject;
36
40
  Button: new () => CSObject;
37
41
  TextField: new () => CSObject;
@@ -49,6 +53,15 @@ declare const CS: {
49
53
  ListViewReorderMode: CSEnum;
50
54
  AlternatingRowBackground: CSEnum;
51
55
  CollectionVirtualizationMethod: CSEnum;
56
+ DisplayStyle: CSEnum;
57
+ };
58
+ };
59
+ OneJS: {
60
+ GPU: {
61
+ GPUBridge: {
62
+ SetElementBackgroundImage: (element: CSObject, rtHandle: number) => void;
63
+ ClearElementBackgroundImage: (element: CSObject) => void;
64
+ };
52
65
  };
53
66
  };
54
67
  };
@@ -59,24 +72,6 @@ declare const __eventAPI: {
59
72
  removeAllEventListeners: (element: CSObject) => void;
60
73
  };
61
74
 
62
- interface CSObject {
63
- __csHandle: number;
64
- __csType: string;
65
- Add: (child: CSObject) => void;
66
- Insert: (index: number, child: CSObject) => void;
67
- Remove: (child: CSObject) => void;
68
- RemoveAt: (index: number) => void;
69
- IndexOf: (child: CSObject) => number;
70
- Clear: () => void;
71
- style: CSStyle;
72
- text?: string;
73
- value?: unknown;
74
- label?: string;
75
- AddToClassList: (className: string) => void;
76
- RemoveFromClassList: (className: string) => void;
77
- ClearClassList: () => void;
78
- }
79
-
80
75
  interface CSStyle {
81
76
  [key: string]: unknown;
82
77
  }
@@ -132,6 +127,10 @@ interface CSListView extends CSObject {
132
127
  Rebuild: () => void;
133
128
  }
134
129
 
130
+ // Elements that merge text children into their text property instead of adding as visual children
131
+ // This enables <Label>Hello {"World"}</Label> to render as single line, matching React Native behavior
132
+ const TEXT_MERGE_TYPES = new Set(['ojs-label', 'ojs-text', 'ojs-button']);
133
+
135
134
  // Instance type used by the reconciler
136
135
  export interface Instance {
137
136
  element: CSObject;
@@ -139,6 +138,14 @@ export interface Instance {
139
138
  props: BaseProps;
140
139
  eventHandlers: Map<string, Function>;
141
140
  appliedStyleKeys: Set<string>; // Track which style properties are currently applied
141
+ // For text-merging parents: ordered list of merged text children
142
+ mergedTextChildren?: Instance[];
143
+ // For merged text children: reference to parent they're merged into
144
+ mergedInto?: Instance;
145
+ // Set to true when a non-text child is added, disabling further text merging
146
+ hasMixedContent?: boolean;
147
+ // For vector drawing: track the current generateVisualContent callback
148
+ visualContentCallback?: GenerateVisualContentCallback;
142
149
  }
143
150
 
144
151
  export type TextInstance = Instance; // For Label elements with text content
@@ -149,6 +156,7 @@ export type ChildSet = never; // Not using persistent mode
149
156
  // Element types use 'ojs-' prefix to avoid conflicts with HTML/SVG in @types/react
150
157
  const TYPE_MAP: Record<string, () => CSObject> = {
151
158
  'ojs-view': () => new CS.UnityEngine.UIElements.VisualElement(),
159
+ 'ojs-text': () => new CS.UnityEngine.UIElements.TextElement(),
152
160
  'ojs-label': () => new CS.UnityEngine.UIElements.Label(),
153
161
  'ojs-button': () => new CS.UnityEngine.UIElements.Button(),
154
162
  'ojs-textfield': () => new CS.UnityEngine.UIElements.TextField(),
@@ -161,17 +169,68 @@ const TYPE_MAP: Record<string, () => CSObject> = {
161
169
 
162
170
  // Event prop to event type mapping
163
171
  const EVENT_PROPS: Record<string, string> = {
172
+ // Click
164
173
  onClick: 'click',
174
+
175
+ // Pointer events
165
176
  onPointerDown: 'pointerdown',
166
177
  onPointerUp: 'pointerup',
167
178
  onPointerMove: 'pointermove',
168
179
  onPointerEnter: 'pointerenter',
169
180
  onPointerLeave: 'pointerleave',
181
+ onPointerCancel: 'pointercancel',
182
+ onPointerCapture: 'pointercapture',
183
+ onPointerCaptureOut: 'pointercaptureout',
184
+ onPointerStationary: 'pointerstationary',
185
+
186
+ // Mouse events
187
+ onMouseDown: 'mousedown',
188
+ onMouseUp: 'mouseup',
189
+ onMouseMove: 'mousemove',
190
+ onMouseEnter: 'mouseenter',
191
+ onMouseLeave: 'mouseleave',
192
+ onMouseOver: 'mouseover',
193
+ onMouseOut: 'mouseout',
194
+ onWheel: 'wheel',
195
+ onContextClick: 'contextclick',
196
+
197
+ // Focus events
170
198
  onFocus: 'focus',
171
199
  onBlur: 'blur',
200
+ onFocusIn: 'focusin',
201
+ onFocusOut: 'focusout',
202
+
203
+ // Keyboard events
172
204
  onKeyDown: 'keydown',
173
205
  onKeyUp: 'keyup',
206
+
207
+ // Input events
174
208
  onChange: 'change',
209
+ onInput: 'input',
210
+
211
+ // Drag events
212
+ onDragEnter: 'dragenter',
213
+ onDragLeave: 'dragleave',
214
+ onDragUpdated: 'dragupdated',
215
+ onDragPerform: 'dragperform',
216
+ onDragExited: 'dragexited',
217
+
218
+ // Geometry events
219
+ onGeometryChanged: 'geometrychanged',
220
+
221
+ // Navigation events
222
+ onNavigationMove: 'navigationmove',
223
+ onNavigationSubmit: 'navigationsubmit',
224
+ onNavigationCancel: 'navigationcancel',
225
+
226
+ // Tooltip
227
+ onTooltip: 'tooltip',
228
+
229
+ // Transition events
230
+ onTransitionRun: 'transitionrun',
231
+ onTransitionStart: 'transitionstart',
232
+ onTransitionEnd: 'transitionend',
233
+ onTransitionCancel: 'transitioncancel',
175
234
  };
176
235
 
177
236
  // Shorthand style properties that expand to multiple properties
@@ -237,6 +296,35 @@ function getExpandedStyleKeys(style: ViewStyle | undefined): Set<string> {
237
296
  return keys;
238
297
  }
239
298
 
299
+ /**
300
+ * RenderTexture-like object for backgroundImage style property.
301
+ * Can be either:
302
+ * - A marker object with __rtHandle (from rt.getUnityObject())
303
+ * - A RenderTexture object directly (has __handle property)
304
+ */
305
+ interface RenderTextureRef {
306
+ __rtHandle?: number;
307
+ __handle?: number;
308
+ }
309
+
310
+ /**
311
+ * Check if a value is a RenderTexture or RenderTexture handle marker.
312
+ * Supports both:
313
+ * - Direct RenderTexture objects (have __handle)
314
+ * - Marker objects from getUnityObject() (have __rtHandle)
315
+ */
316
+ function isRenderTextureHandle(value: unknown): value is RenderTextureRef {
317
+ if (typeof value !== "object" || value === null) return false;
318
+ return "__rtHandle" in value || "__handle" in value;
319
+ }
320
+
321
+ /**
322
+ * Get the RT handle from a RenderTexture-like object.
323
+ */
324
+ function getRenderTextureHandle(value: RenderTextureRef): number {
325
+ return value.__rtHandle ?? value.__handle ?? -1;
326
+ }
327
+
240
328
  // Apply style properties to element, returns the set of applied keys
241
329
  function applyStyle(element: CSObject, style: ViewStyle | undefined): Set<string> {
242
330
  const appliedKeys = new Set<string>();
@@ -255,6 +343,14 @@ function applyStyle(element: CSObject, style: ViewStyle | undefined): Set<string
255
343
  s[prop] = parsed;
256
344
  appliedKeys.add(prop);
257
345
  }
346
+ } else if (key === "backgroundImage" && isRenderTextureHandle(value)) {
347
+ // Special handling for RenderTexture background images
348
+ // Supports both direct RenderTexture objects and marker objects
349
+ const handle = getRenderTextureHandle(value);
350
+ if (handle >= 0) {
351
+ CS.OneJS.GPU.GPUBridge.SetElementBackgroundImage(element, handle);
352
+ }
353
+ appliedKeys.add(key);
258
354
  } else {
259
355
  // Parse and apply individual property
260
356
  s[key] = parseStyleValue(key, value);
@@ -269,8 +365,13 @@ function clearRemovedStyles(element: CSObject, oldKeys: Set<string>, newKeys: Se
269
365
  const s = element.style;
270
366
  for (const key of oldKeys) {
271
367
  if (!newKeys.has(key)) {
272
- // Setting to undefined clears the inline style, falling back to USS
273
- s[key] = undefined;
368
+ if (key === "backgroundImage") {
369
+ // Special handling for backgroundImage - use GPUBridge to clear
370
+ CS.OneJS.GPU.GPUBridge.ClearElementBackgroundImage(element);
371
+ } else {
372
+ // Setting to undefined clears the inline style, falling back to USS
373
+ s[key] = undefined;
374
+ }
274
375
  }
275
376
  }
276
377
  }
@@ -334,126 +435,325 @@ function applyEvents(instance: Instance, props: BaseProps) {
334
435
  }
335
436
  }
336
437
 
337
- // Apply component-specific props
338
- function applyComponentProps(element: CSObject, type: string, props: Record<string, unknown>) {
339
- // For Label, Button - set text property directly
340
- if (props.text !== undefined) {
341
- (element as { text: string }).text = props.text as string;
438
+ /**
439
+ * Apply generateVisualContent callback for vector drawing.
440
+ * Uses Unity's generateVisualContent delegate on VisualElement.
441
+ *
442
+ * This follows the same pattern as ListView's makeItem/bindItem callbacks -
443
+ * we assign JS functions directly to C# delegate properties via the interop layer.
444
+ */
445
+ function applyVisualContentCallback(instance: Instance, props: BaseProps) {
446
+ const callback = props.onGenerateVisualContent;
447
+ const existingCallback = instance.visualContentCallback;
448
+
449
+ if (callback !== existingCallback) {
450
+ const element = instance.element as unknown as { generateVisualContent: GenerateVisualContentCallback | null };
451
+
452
+ // Remove old callback if exists
453
+ if (existingCallback) {
454
+ // Clear the delegate via C# interop
455
+ element.generateVisualContent = null;
456
+ }
457
+
458
+ // Add new callback if provided
459
+ if (callback) {
460
+ // Assign callback to generateVisualContent property
461
+ // The C# interop layer handles the delegate conversion
462
+ element.generateVisualContent = callback;
463
+ instance.visualContentCallback = callback;
464
+ } else {
465
+ instance.visualContentCallback = undefined;
466
+ }
342
467
  }
343
- // For TextField, Toggle, Slider - set value property
344
- if (props.value !== undefined) {
345
- (element as { value: unknown }).value = props.value;
468
+ }
469
+
470
+ // MARK: Text Merging
471
+ // Rebuild concatenated text from merged text children
472
+ function rebuildMergedText(instance: Instance) {
473
+ const children = instance.mergedTextChildren;
474
+ if (!children || children.length === 0) {
475
+ // No merged children - clear text (or keep prop-based text?)
476
+ // For now, set to empty - if user wants text, they should use children
477
+ instance.element.text = '';
478
+ return;
346
479
  }
347
- // For input elements that have a label
348
- if (props.label !== undefined) {
349
- (element as { label: string }).label = props.label as string;
480
+ instance.element.text = children.map(c => c.element.text || '').join('');
481
+ }
482
+
483
+ // Check if a child should be merged into parent's text property
484
+ function shouldMergeText(parentInstance: Instance, child: Instance): boolean {
485
+ // Don't merge if parent has mixed content (non-text children were added)
486
+ if (parentInstance.hasMixedContent) return false;
487
+ return TEXT_MERGE_TYPES.has(parentInstance.type) && child.type === 'text';
488
+ }
489
+
490
+ // "Unmerge" all text children - add them as actual visual children
491
+ // Called when a non-text child is added, breaking the pure-text assumption
492
+ function unmergTextChildren(parentInstance: Instance) {
493
+ const children = parentInstance.mergedTextChildren;
494
+ if (!children || children.length === 0) return;
495
+
496
+ // Clear parent's merged text
497
+ parentInstance.element.text = '';
498
+
499
+ // Add each merged text child as an actual visual child
500
+ for (const child of children) {
501
+ child.mergedInto = undefined;
502
+ parentInstance.element.Add(child.element);
350
503
  }
351
504
 
352
- // ScrollView-specific properties
353
- if (type === 'ojs-scrollview') {
354
- const sv = element as CSScrollView;
355
- if (props.mode !== undefined) {
356
- sv.mode = CS.UnityEngine.UIElements.ScrollViewMode[props.mode as string];
357
- }
358
- if (props.horizontalScrollerVisibility !== undefined) {
359
- sv.horizontalScrollerVisibility = CS.UnityEngine.UIElements.ScrollerVisibility[props.horizontalScrollerVisibility as string];
360
- }
361
- if (props.verticalScrollerVisibility !== undefined) {
362
- sv.verticalScrollerVisibility = CS.UnityEngine.UIElements.ScrollerVisibility[props.verticalScrollerVisibility as string];
363
- }
364
- if (props.elasticity !== undefined) {
365
- sv.elasticity = props.elasticity as number;
366
- }
367
- if (props.elasticAnimationIntervalMs !== undefined) {
368
- sv.elasticAnimationIntervalMs = props.elasticAnimationIntervalMs as number;
369
- }
370
- if (props.scrollDecelerationRate !== undefined) {
371
- sv.scrollDecelerationRate = props.scrollDecelerationRate as number;
372
- }
373
- if (props.mouseWheelScrollSize !== undefined) {
374
- sv.mouseWheelScrollSize = props.mouseWheelScrollSize as number;
375
- }
376
- if (props.horizontalPageSize !== undefined) {
377
- sv.horizontalPageSize = props.horizontalPageSize as number;
378
- }
379
- if (props.verticalPageSize !== undefined) {
380
- sv.verticalPageSize = props.verticalPageSize as number;
381
- }
382
- if (props.touchScrollBehavior !== undefined) {
383
- sv.touchScrollBehavior = CS.UnityEngine.UIElements.TouchScrollBehavior[props.touchScrollBehavior as string];
384
- }
385
- if (props.nestedInteractionKind !== undefined) {
386
- sv.nestedInteractionKind = CS.UnityEngine.UIElements.NestedInteractionKind[props.nestedInteractionKind as string];
387
- }
505
+ // Clear the merged children list
506
+ parentInstance.mergedTextChildren = undefined;
507
+ parentInstance.hasMixedContent = true;
508
+ }
509
+
510
+ // Handle adding a non-text child to a text-merge parent
511
+ // This triggers unmerging of any previously merged text
512
+ function handleNonTextChild(parentInstance: Instance) {
513
+ if (TEXT_MERGE_TYPES.has(parentInstance.type) && !parentInstance.hasMixedContent) {
514
+ unmergTextChildren(parentInstance);
388
515
  }
516
+ }
389
517
 
390
- // ListView-specific properties
391
- if (type === 'ojs-listview') {
392
- const lv = element as CSListView;
518
+ // Append a text child to a text-merging parent
519
+ function appendMergedTextChild(parentInstance: Instance, child: Instance) {
520
+ if (!parentInstance.mergedTextChildren) {
521
+ parentInstance.mergedTextChildren = [];
522
+ }
523
+ parentInstance.mergedTextChildren.push(child);
524
+ child.mergedInto = parentInstance;
525
+ rebuildMergedText(parentInstance);
526
+ }
393
527
 
394
- // Data binding - these are the core callbacks
395
- if (props.itemsSource !== undefined) {
396
- lv.itemsSource = props.itemsSource as unknown[];
397
- }
398
- if (props.makeItem !== undefined) {
399
- lv.makeItem = props.makeItem as () => CSObject;
400
- }
401
- if (props.bindItem !== undefined) {
402
- lv.bindItem = props.bindItem as (element: CSObject, index: number) => void;
403
- }
404
- if (props.unbindItem !== undefined) {
405
- lv.unbindItem = props.unbindItem as (element: CSObject, index: number) => void;
406
- }
407
- if (props.destroyItem !== undefined) {
408
- lv.destroyItem = props.destroyItem as (element: CSObject) => void;
409
- }
528
+ // Insert a text child before another in a text-merging parent
529
+ function insertMergedTextChild(parentInstance: Instance, child: Instance, beforeChild: Instance) {
530
+ if (!parentInstance.mergedTextChildren) {
531
+ parentInstance.mergedTextChildren = [];
532
+ }
533
+ const index = parentInstance.mergedTextChildren.indexOf(beforeChild);
534
+ if (index >= 0) {
535
+ parentInstance.mergedTextChildren.splice(index, 0, child);
536
+ } else {
537
+ parentInstance.mergedTextChildren.push(child);
538
+ }
539
+ child.mergedInto = parentInstance;
540
+ rebuildMergedText(parentInstance);
541
+ }
410
542
 
411
- // Virtualization
412
- if (props.fixedItemHeight !== undefined) {
413
- lv.fixedItemHeight = props.fixedItemHeight as number;
414
- }
415
- if (props.virtualizationMethod !== undefined) {
416
- lv.virtualizationMethod = CS.UnityEngine.UIElements.CollectionVirtualizationMethod[props.virtualizationMethod as string];
543
+ // Remove a text child from a text-merging parent
544
+ function removeMergedTextChild(parentInstance: Instance, child: Instance) {
545
+ const children = parentInstance.mergedTextChildren;
546
+ if (children) {
547
+ const index = children.indexOf(child);
548
+ if (index >= 0) {
549
+ children.splice(index, 1);
417
550
  }
551
+ }
552
+ child.mergedInto = undefined;
553
+ rebuildMergedText(parentInstance);
554
+ }
418
555
 
419
- // Selection
420
- if (props.selectionType !== undefined) {
421
- lv.selectionType = CS.UnityEngine.UIElements.SelectionType[props.selectionType as string];
422
- }
423
- if (props.selectedIndex !== undefined) {
424
- lv.selectedIndex = props.selectedIndex as number;
425
- }
426
- if (props.selectedIndices !== undefined) {
427
- lv.selectedIndices = props.selectedIndices as number[];
428
- }
556
+ // MARK: Component-specific prop handlers
429
557
 
430
- // Reordering
431
- if (props.reorderable !== undefined) {
432
- lv.reorderable = props.reorderable as boolean;
433
- }
434
- if (props.reorderMode !== undefined) {
435
- lv.reorderMode = CS.UnityEngine.UIElements.ListViewReorderMode[props.reorderMode as string];
436
- }
558
+ // Apply common props (text, value, label)
559
+ function applyCommonProps(element: CSObject, props: Record<string, unknown>) {
560
+ if (props.text !== undefined) (element as { text: string }).text = props.text as string;
561
+ if (props.value !== undefined) (element as { value: unknown }).value = props.value;
562
+ if (props.label !== undefined) (element as { label: string }).label = props.label as string;
563
+ }
437
564
 
438
- // Header/Footer
439
- if (props.showFoldoutHeader !== undefined) {
440
- lv.showFoldoutHeader = props.showFoldoutHeader as boolean;
441
- }
442
- if (props.headerTitle !== undefined) {
443
- lv.headerTitle = props.headerTitle as string;
444
- }
445
- if (props.showAddRemoveFooter !== undefined) {
446
- lv.showAddRemoveFooter = props.showAddRemoveFooter as boolean;
447
- }
565
+ // Helper to set enum prop if defined
566
+ function setEnumProp<T>(target: T, key: keyof T, props: Record<string, unknown>, propKey: string, enumType: CSEnum) {
567
+ if (props[propKey] !== undefined) {
568
+ (target as Record<string, unknown>)[key as string] = enumType[props[propKey] as string];
569
+ }
570
+ }
448
571
 
449
- // Appearance
450
- if (props.showBorder !== undefined) {
451
- lv.showBorder = props.showBorder as boolean;
452
- }
453
- if (props.showAlternatingRowBackgrounds !== undefined) {
454
- lv.showAlternatingRowBackgrounds = CS.UnityEngine.UIElements.AlternatingRowBackground[props.showAlternatingRowBackgrounds as string];
572
+ // Helper to set value prop if defined
573
+ function setValueProp<T>(target: T, key: keyof T, props: Record<string, unknown>, propKey: string) {
574
+ if (props[propKey] !== undefined) {
575
+ (target as Record<string, unknown>)[key as string] = props[propKey];
576
+ }
577
+ }
578
+
579
+ // Apply TextField-specific properties
580
+ function applyTextFieldProps(element: CSObject, props: Record<string, unknown>) {
581
+ // Map readOnly prop to isReadOnly property
582
+ if (props.readOnly !== undefined) {
583
+ (element as { isReadOnly: boolean }).isReadOnly = props.readOnly as boolean;
584
+ }
585
+ if (props.multiline !== undefined) {
586
+ (element as { multiline: boolean }).multiline = props.multiline as boolean;
587
+ }
588
+ if (props.maxLength !== undefined) {
589
+ (element as { maxLength: number }).maxLength = props.maxLength as number;
590
+ }
591
+ if (props.isPasswordField !== undefined) {
592
+ (element as { isPasswordField: boolean }).isPasswordField = props.isPasswordField as boolean;
593
+ }
594
+ if (props.maskChar !== undefined) {
595
+ (element as { maskChar: string }).maskChar = (props.maskChar as string).charAt(0);
596
+ }
597
+ if (props.isDelayed !== undefined) {
598
+ (element as { isDelayed: boolean }).isDelayed = props.isDelayed as boolean;
599
+ }
600
+ if (props.selectAllOnFocus !== undefined) {
601
+ (element as { selectAllOnFocus: boolean }).selectAllOnFocus = props.selectAllOnFocus as boolean;
602
+ }
603
+ if (props.selectAllOnMouseUp !== undefined) {
604
+ (element as { selectAllOnMouseUp: boolean }).selectAllOnMouseUp = props.selectAllOnMouseUp as boolean;
605
+ }
606
+ if (props.hideMobileInput !== undefined) {
607
+ (element as { hideMobileInput: boolean }).hideMobileInput = props.hideMobileInput as boolean;
608
+ }
609
+ if (props.autoCorrection !== undefined) {
610
+ (element as { autoCorrection: boolean }).autoCorrection = props.autoCorrection as boolean;
611
+ }
612
+ // Note: placeholder is handled differently in Unity - it's set via the textEdition interface
613
+ // For now we skip it as it requires more complex handling
614
+ }
615
+
616
+ // Apply Slider-specific properties
617
+ function applySliderProps(element: CSObject, props: Record<string, unknown>) {
618
+ if (props.lowValue !== undefined) {
619
+ (element as { lowValue: number }).lowValue = props.lowValue as number;
620
+ }
621
+ if (props.highValue !== undefined) {
622
+ (element as { highValue: number }).highValue = props.highValue as number;
623
+ }
624
+ if (props.showInputField !== undefined) {
625
+ (element as { showInputField: boolean }).showInputField = props.showInputField as boolean;
626
+ }
627
+ if (props.inverted !== undefined) {
628
+ (element as { inverted: boolean }).inverted = props.inverted as boolean;
629
+ }
630
+ if (props.pageSize !== undefined) {
631
+ (element as { pageSize: number }).pageSize = props.pageSize as number;
632
+ }
633
+ if (props.fill !== undefined) {
634
+ (element as { fill: boolean }).fill = props.fill as boolean;
635
+ }
636
+ if (props.direction !== undefined) {
637
+ const UIE = CS.UnityEngine.UIElements;
638
+ (element as { direction: unknown }).direction = UIE.SliderDirection[props.direction as string];
639
+ }
640
+ }
641
+
642
+ // Apply Toggle-specific properties
643
+ function applyToggleProps(element: CSObject, props: Record<string, unknown>) {
644
+ if (props.text !== undefined) {
645
+ (element as { text: string }).text = props.text as string;
646
+ }
647
+ if (props.toggleOnLabelClick !== undefined) {
648
+ (element as { toggleOnLabelClick: boolean }).toggleOnLabelClick = props.toggleOnLabelClick as boolean;
649
+ }
650
+ }
651
+
652
+ // Apply Image-specific properties
653
+ function applyImageProps(element: CSObject, props: Record<string, unknown>) {
654
+ if (props.image !== undefined) {
655
+ (element as { image: unknown }).image = props.image;
656
+ }
657
+ if (props.sprite !== undefined) {
658
+ (element as { sprite: unknown }).sprite = props.sprite;
659
+ }
660
+ if (props.vectorImage !== undefined) {
661
+ (element as { vectorImage: unknown }).vectorImage = props.vectorImage;
662
+ }
663
+ if (props.scaleMode !== undefined) {
664
+ const scaleMode = CS.UnityEngine.ScaleMode[props.scaleMode as string];
665
+ (element as { scaleMode: unknown }).scaleMode = scaleMode;
666
+ }
667
+ if (props.tintColor !== undefined) {
668
+ // Parse color string to Unity Color
669
+ const color = parseColor(props.tintColor as string);
670
+ if (color) {
671
+ (element as { tintColor: unknown }).tintColor = color;
455
672
  }
456
673
  }
674
+ if (props.sourceRect !== undefined) {
675
+ const rect = props.sourceRect as { x: number; y: number; width: number; height: number };
676
+ (element as { sourceRect: unknown }).sourceRect = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
677
+ }
678
+ if (props.uv !== undefined) {
679
+ const rect = props.uv as { x: number; y: number; width: number; height: number };
680
+ (element as { uv: unknown }).uv = new CS.UnityEngine.Rect(rect.x, rect.y, rect.width, rect.height);
681
+ }
682
+ }
683
+
684
+ // Apply ScrollView-specific properties
685
+ function applyScrollViewProps(element: CSScrollView, props: Record<string, unknown>) {
686
+ const UIE = CS.UnityEngine.UIElements;
687
+ setEnumProp(element, 'mode', props, 'mode', UIE.ScrollViewMode);
688
+ setEnumProp(element, 'horizontalScrollerVisibility', props, 'horizontalScrollerVisibility', UIE.ScrollerVisibility);
689
+ setEnumProp(element, 'verticalScrollerVisibility', props, 'verticalScrollerVisibility', UIE.ScrollerVisibility);
690
+ setEnumProp(element, 'touchScrollBehavior', props, 'touchScrollBehavior', UIE.TouchScrollBehavior);
691
+ setEnumProp(element, 'nestedInteractionKind', props, 'nestedInteractionKind', UIE.NestedInteractionKind);
692
+ setValueProp(element, 'elasticity', props, 'elasticity');
693
+ setValueProp(element, 'elasticAnimationIntervalMs', props, 'elasticAnimationIntervalMs');
694
+ setValueProp(element, 'scrollDecelerationRate', props, 'scrollDecelerationRate');
695
+ setValueProp(element, 'mouseWheelScrollSize', props, 'mouseWheelScrollSize');
696
+ setValueProp(element, 'horizontalPageSize', props, 'horizontalPageSize');
697
+ setValueProp(element, 'verticalPageSize', props, 'verticalPageSize');
698
+ }
699
+
700
+ // Apply ListView-specific properties
701
+ function applyListViewProps(element: CSListView, props: Record<string, unknown>) {
702
+ const UIE = CS.UnityEngine.UIElements;
703
+
704
+ // Data binding callbacks
705
+ setValueProp(element, 'itemsSource', props, 'itemsSource');
706
+ setValueProp(element, 'makeItem', props, 'makeItem');
707
+ setValueProp(element, 'bindItem', props, 'bindItem');
708
+ setValueProp(element, 'unbindItem', props, 'unbindItem');
709
+ setValueProp(element, 'destroyItem', props, 'destroyItem');
710
+
711
+ // Virtualization
712
+ setValueProp(element, 'fixedItemHeight', props, 'fixedItemHeight');
713
+ setEnumProp(element, 'virtualizationMethod', props, 'virtualizationMethod', UIE.CollectionVirtualizationMethod);
714
+
715
+ // Selection
716
+ setEnumProp(element, 'selectionType', props, 'selectionType', UIE.SelectionType);
717
+ setValueProp(element, 'selectedIndex', props, 'selectedIndex');
718
+ setValueProp(element, 'selectedIndices', props, 'selectedIndices');
719
+
720
+ // Reordering
721
+ setValueProp(element, 'reorderable', props, 'reorderable');
722
+ setEnumProp(element, 'reorderMode', props, 'reorderMode', UIE.ListViewReorderMode);
723
+
724
+ // Header/Footer
725
+ setValueProp(element, 'showFoldoutHeader', props, 'showFoldoutHeader');
726
+ setValueProp(element, 'headerTitle', props, 'headerTitle');
727
+ setValueProp(element, 'showAddRemoveFooter', props, 'showAddRemoveFooter');
728
+
729
+ // Appearance
730
+ setValueProp(element, 'showBorder', props, 'showBorder');
731
+ setEnumProp(element, 'showAlternatingRowBackgrounds', props, 'showAlternatingRowBackgrounds', UIE.AlternatingRowBackground);
732
+ }
733
+
734
+ // Apply component-specific props based on element type
735
+ function applyComponentProps(element: CSObject, type: string, props: Record<string, unknown>) {
736
+ // For Slider, apply range props (lowValue/highValue) BEFORE value
737
+ // Unity's Slider clamps value to [lowValue, highValue], so range must be set first
738
+ if (type === 'ojs-slider') {
739
+ applySliderProps(element, props);
740
+ applyCommonProps(element, props);
741
+ return;
742
+ }
743
+
744
+ applyCommonProps(element, props);
745
+
746
+ if (type === 'ojs-textfield') {
747
+ applyTextFieldProps(element, props);
748
+ } else if (type === 'ojs-toggle') {
749
+ applyToggleProps(element, props);
750
+ } else if (type === 'ojs-image') {
751
+ applyImageProps(element, props);
752
+ } else if (type === 'ojs-scrollview') {
753
+ applyScrollViewProps(element as CSScrollView, props);
754
+ } else if (type === 'ojs-listview') {
755
+ applyListViewProps(element as CSListView, props);
756
+ }
457
757
  }
458
758
 
459
759
  // Create an instance
@@ -475,6 +775,7 @@ function createInstance(type: string, props: BaseProps): Instance {
475
775
 
476
776
  applyClassName(element, props.className);
477
777
  applyEvents(instance, props);
778
+ applyVisualContentCallback(instance, props);
478
779
  applyComponentProps(element, type, props as Record<string, unknown>);
479
780
 
480
781
  return instance;
@@ -499,14 +800,20 @@ function updateInstance(instance: Instance, oldProps: BaseProps, newProps: BaseP
499
800
  // Update events
500
801
  applyEvents(instance, newProps);
501
802
 
803
+ // Update vector drawing callback
804
+ applyVisualContentCallback(instance, newProps);
805
+
502
806
  // Update component-specific props
503
807
  applyComponentProps(element, instance.type, newProps as Record<string, unknown>);
504
808
 
505
809
  instance.props = newProps;
506
810
  }
507
811
 
508
- // The host config for react-reconciler
509
- export const hostConfig: HostConfig<
812
+ // NOTE: We use a type assertion because @types/react-reconciler (0.28.x) is outdated
813
+ // and doesn't match react-reconciler 0.31.x (React 19). The HostConfig interface
814
+ // has changed significantly - notably commitUpdate no longer receives updatePayload.
815
+ // Once @types/react-reconciler is updated for React 19, we can use proper typing.
816
+ type OurHostConfig = HostConfig<
510
817
  string, // Type
511
818
  BaseProps, // Props
512
819
  Container, // Container
@@ -514,13 +821,16 @@ export const hostConfig: HostConfig<
514
821
  TextInstance, // TextInstance
515
822
  never, // SuspenseInstance
516
823
  never, // HydratableInstance
517
- Instance, // PublicInstance
824
+ CSObject, // PublicInstance - refs point to the actual UI Toolkit element
518
825
  {}, // HostContext
519
826
  true, // UpdatePayload (true = needs update)
520
827
  ChildSet, // ChildSet
521
828
  number, // TimeoutHandle
522
829
  number // NoTimeout
523
- > = {
830
+ >;
831
+
832
+ // The host config for react-reconciler
833
+ export const hostConfig = {
524
834
  supportsMutation: true,
525
835
  supportsPersistence: false,
526
836
  supportsHydration: false,
@@ -528,13 +838,16 @@ export const hostConfig: HostConfig<
528
838
  isPrimaryRenderer: true,
529
839
  noTimeout: -1,
530
840
 
531
- createInstance(type, props) {
841
+ createInstance(type: string, props: BaseProps) {
532
842
  return createInstance(type, props);
533
843
  },
534
844
 
535
- createTextInstance(text) {
536
- // Create a Label for text content
537
- const element = new CS.UnityEngine.UIElements.Label();
845
+ createTextInstance(text: string) {
846
+ // Create a TextElement for implicit text content
847
+ // Using TextElement (not Label) for semantic clarity:
848
+ // - TextElement = raw text content in JSX
849
+ // - Label = explicit <Label> component
850
+ const element = new CS.UnityEngine.UIElements.TextElement();
538
851
  element.text = text;
539
852
  return {
540
853
  element,
@@ -545,28 +858,46 @@ export const hostConfig: HostConfig<
545
858
  };
546
859
  },
547
860
 
548
- appendInitialChild(parentInstance, child) {
549
- parentInstance.element.Add(child.element);
861
+ appendInitialChild(parentInstance: Instance, child: Instance) {
862
+ if (shouldMergeText(parentInstance, child)) {
863
+ appendMergedTextChild(parentInstance, child);
864
+ } else {
865
+ // Non-text child - unmerge any previously merged text first
866
+ handleNonTextChild(parentInstance);
867
+ parentInstance.element.Add(child.element);
868
+ }
550
869
  },
551
870
 
552
- appendChild(parentInstance, child) {
553
- parentInstance.element.Add(child.element);
871
+ appendChild(parentInstance: Instance, child: Instance) {
872
+ if (shouldMergeText(parentInstance, child)) {
873
+ appendMergedTextChild(parentInstance, child);
874
+ } else {
875
+ // Non-text child - unmerge any previously merged text first
876
+ handleNonTextChild(parentInstance);
877
+ parentInstance.element.Add(child.element);
878
+ }
554
879
  },
555
880
 
556
- appendChildToContainer(container, child) {
881
+ appendChildToContainer(container: Container, child: Instance) {
557
882
  container.Add(child.element);
558
883
  },
559
884
 
560
- insertBefore(parentInstance, child, beforeChild) {
561
- const index = parentInstance.element.IndexOf(beforeChild.element);
562
- if (index >= 0) {
563
- parentInstance.element.Insert(index, child.element);
885
+ insertBefore(parentInstance: Instance, child: Instance, beforeChild: Instance) {
886
+ if (shouldMergeText(parentInstance, child)) {
887
+ insertMergedTextChild(parentInstance, child, beforeChild);
564
888
  } else {
565
- parentInstance.element.Add(child.element);
889
+ // Non-text child - unmerge any previously merged text first
890
+ handleNonTextChild(parentInstance);
891
+ const index = parentInstance.element.IndexOf(beforeChild.element);
892
+ if (index >= 0) {
893
+ parentInstance.element.Insert(index, child.element);
894
+ } else {
895
+ parentInstance.element.Add(child.element);
896
+ }
566
897
  }
567
898
  },
568
899
 
569
- insertInContainerBefore(container, child, beforeChild) {
900
+ insertInContainerBefore(container: Container, child: Instance, beforeChild: Instance) {
570
901
  const index = container.IndexOf(beforeChild.element);
571
902
  if (index >= 0) {
572
903
  container.Insert(index, child.element);
@@ -575,37 +906,47 @@ export const hostConfig: HostConfig<
575
906
  }
576
907
  },
577
908
 
578
- removeChild(parentInstance, child) {
579
- __eventAPI.removeAllEventListeners(child.element);
580
- parentInstance.element.Remove(child.element);
909
+ removeChild(parentInstance: Instance, child: Instance) {
910
+ if (child.mergedInto === parentInstance) {
911
+ // Child was merged into parent's text - remove from merged children
912
+ removeMergedTextChild(parentInstance, child);
913
+ } else {
914
+ __eventAPI.removeAllEventListeners(child.element);
915
+ parentInstance.element.Remove(child.element);
916
+ }
581
917
  },
582
918
 
583
- removeChildFromContainer(container, child) {
919
+ removeChildFromContainer(container: Container, child: Instance) {
584
920
  __eventAPI.removeAllEventListeners(child.element);
585
921
  container.Remove(child.element);
586
922
  },
587
923
 
588
- prepareUpdate(_instance, _type, oldProps, newProps) {
924
+ prepareUpdate(_instance: Instance, _type: string, oldProps: BaseProps, newProps: BaseProps) {
589
925
  // Return true if we need to update, null if no update needed
590
926
  return oldProps !== newProps ? true : null;
591
927
  },
592
928
 
593
929
  // React 19 changed the signature: (instance, type, oldProps, newProps, fiber)
594
930
  // The updatePayload parameter was removed!
595
- commitUpdate(instance, _type, oldProps, newProps, _fiber) {
931
+ commitUpdate(instance: Instance, _type: string, oldProps: BaseProps, newProps: BaseProps, _fiber: unknown) {
596
932
  updateInstance(instance, oldProps, newProps);
597
933
  },
598
934
 
599
- commitTextUpdate(textInstance, _oldText, newText) {
935
+ commitTextUpdate(textInstance: Instance, _oldText: string, newText: string) {
600
936
  textInstance.element.text = newText;
937
+ // If this text is merged into a parent, rebuild the parent's concatenated text
938
+ if (textInstance.mergedInto) {
939
+ rebuildMergedText(textInstance.mergedInto);
940
+ }
601
941
  },
602
942
 
603
943
  finalizeInitialChildren() {
604
944
  return false;
605
945
  },
606
946
 
607
- getPublicInstance(instance) {
608
- return instance;
947
+ getPublicInstance(instance: Instance) {
948
+ // Return the actual UI Toolkit element so refs point to it
949
+ return instance.element;
609
950
  },
610
951
 
611
952
  prepareForCommit() {
@@ -624,7 +965,7 @@ export const hostConfig: HostConfig<
624
965
  return {};
625
966
  },
626
967
 
627
- getChildHostContext(parentHostContext) {
968
+ getChildHostContext(parentHostContext: {}) {
628
969
  return parentHostContext;
629
970
  },
630
971
 
@@ -632,7 +973,7 @@ export const hostConfig: HostConfig<
632
973
  return false;
633
974
  },
634
975
 
635
- clearContainer(container) {
976
+ clearContainer(container: Container) {
636
977
  container.Clear();
637
978
  },
638
979
 
@@ -704,16 +1045,16 @@ export const hostConfig: HostConfig<
704
1045
 
705
1046
  // Visibility support
706
1047
  hideInstance(instance: Instance) {
707
- instance.element.style.display = 'none';
1048
+ instance.element.style.display = CS.UnityEngine.UIElements.DisplayStyle.None;
708
1049
  },
709
1050
  hideTextInstance(textInstance: TextInstance) {
710
- textInstance.element.style.display = 'none';
1051
+ textInstance.element.style.display = CS.UnityEngine.UIElements.DisplayStyle.None;
711
1052
  },
712
1053
  unhideInstance(instance: Instance, _props: BaseProps) {
713
- instance.element.style.display = '';
1054
+ instance.element.style.display = CS.UnityEngine.UIElements.DisplayStyle.Flex;
714
1055
  },
715
1056
  unhideTextInstance(textInstance: TextInstance, _text: string) {
716
- textInstance.element.style.display = '';
1057
+ textInstance.element.style.display = CS.UnityEngine.UIElements.DisplayStyle.Flex;
717
1058
  },
718
1059
 
719
1060
  // Text content
@@ -746,4 +1087,4 @@ export const hostConfig: HostConfig<
746
1087
  bindToConsole(methodName: string, args: unknown[], _badgeName: string) {
747
1088
  return (console as Record<string, Function>)[methodName]?.bind(console, ...args);
748
1089
  },
749
- };
1090
+ } as unknown as OurHostConfig;