onejs-react 0.1.0
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 +43 -0
- package/src/__tests__/components.test.tsx +388 -0
- package/src/__tests__/host-config.test.ts +674 -0
- package/src/__tests__/mocks.ts +311 -0
- package/src/__tests__/renderer.test.tsx +387 -0
- package/src/__tests__/setup.ts +52 -0
- package/src/__tests__/style-parser.test.ts +321 -0
- package/src/components.tsx +87 -0
- package/src/host-config.ts +749 -0
- package/src/index.ts +54 -0
- package/src/renderer.ts +73 -0
- package/src/screen.tsx +242 -0
- package/src/style-parser.ts +288 -0
- package/src/types.ts +295 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Components
|
|
2
|
+
export {
|
|
3
|
+
View,
|
|
4
|
+
Label,
|
|
5
|
+
Button,
|
|
6
|
+
TextField,
|
|
7
|
+
Toggle,
|
|
8
|
+
Slider,
|
|
9
|
+
ScrollView,
|
|
10
|
+
Image,
|
|
11
|
+
ListView,
|
|
12
|
+
} from './components';
|
|
13
|
+
|
|
14
|
+
// Renderer
|
|
15
|
+
export { render, unmount } from './renderer';
|
|
16
|
+
|
|
17
|
+
// Responsive Design
|
|
18
|
+
export {
|
|
19
|
+
ScreenProvider,
|
|
20
|
+
useBreakpoint,
|
|
21
|
+
useScreenSize,
|
|
22
|
+
useResponsive,
|
|
23
|
+
useMediaQuery,
|
|
24
|
+
BREAKPOINTS,
|
|
25
|
+
} from './screen';
|
|
26
|
+
|
|
27
|
+
export type {
|
|
28
|
+
ScreenContextValue,
|
|
29
|
+
ScreenProviderProps,
|
|
30
|
+
BreakpointName,
|
|
31
|
+
} from './screen';
|
|
32
|
+
|
|
33
|
+
// Types
|
|
34
|
+
export type {
|
|
35
|
+
ViewStyle,
|
|
36
|
+
PointerEventData,
|
|
37
|
+
KeyEventData,
|
|
38
|
+
ChangeEventData,
|
|
39
|
+
PointerEventHandler,
|
|
40
|
+
KeyEventHandler,
|
|
41
|
+
ChangeEventHandler,
|
|
42
|
+
FocusEventHandler,
|
|
43
|
+
BaseProps,
|
|
44
|
+
ViewProps,
|
|
45
|
+
LabelProps,
|
|
46
|
+
ButtonProps,
|
|
47
|
+
TextFieldProps,
|
|
48
|
+
ToggleProps,
|
|
49
|
+
SliderProps,
|
|
50
|
+
ScrollViewProps,
|
|
51
|
+
ImageProps,
|
|
52
|
+
VisualElement,
|
|
53
|
+
ListViewProps,
|
|
54
|
+
} from './types';
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Reconciler from 'react-reconciler';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { hostConfig, type Container } from './host-config';
|
|
4
|
+
|
|
5
|
+
declare const console: { log: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
|
6
|
+
|
|
7
|
+
// Create the reconciler
|
|
8
|
+
const reconciler = Reconciler(hostConfig);
|
|
9
|
+
|
|
10
|
+
// Inject into dev tools (helps with proper initialization)
|
|
11
|
+
reconciler.injectIntoDevTools({
|
|
12
|
+
bundleType: 1, // 0 for prod, 1 for dev
|
|
13
|
+
version: '19.0.0',
|
|
14
|
+
rendererPackageName: 'onejs-react',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Track roots for hot reload / re-render
|
|
18
|
+
const roots = new Map<Container, ReturnType<typeof reconciler.createContainer>>();
|
|
19
|
+
|
|
20
|
+
export function render(element: ReactNode, container: Container): void {
|
|
21
|
+
console.log('[onejs-react] render() called');
|
|
22
|
+
let root = roots.get(container);
|
|
23
|
+
|
|
24
|
+
if (!root) {
|
|
25
|
+
console.log('[onejs-react] creating new container');
|
|
26
|
+
root = reconciler.createContainer(
|
|
27
|
+
container,
|
|
28
|
+
0, // LegacyRoot (0) vs ConcurrentRoot (1)
|
|
29
|
+
null, // hydrationCallbacks
|
|
30
|
+
false, // isStrictMode
|
|
31
|
+
null, // concurrentUpdatesByDefaultOverride
|
|
32
|
+
'', // identifierPrefix
|
|
33
|
+
(error: Error) => console.error('[OneJS React] Recoverable error:', error),
|
|
34
|
+
null // transitionCallbacks
|
|
35
|
+
);
|
|
36
|
+
roots.set(container, root);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log('[onejs-react] calling updateContainer');
|
|
40
|
+
reconciler.updateContainer(element, root, null, () => {
|
|
41
|
+
console.log('[onejs-react] updateContainer callback fired');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Try to flush synchronous work
|
|
45
|
+
console.log('[onejs-react] attempting to flush sync work');
|
|
46
|
+
try {
|
|
47
|
+
// flushSync may be exported differently - try flushSyncWork first
|
|
48
|
+
if (typeof (reconciler as any).flushSyncWork === 'function') {
|
|
49
|
+
(reconciler as any).flushSyncWork();
|
|
50
|
+
console.log('[onejs-react] flushSyncWork completed');
|
|
51
|
+
} else if (typeof (reconciler as any).flushSync === 'function') {
|
|
52
|
+
(reconciler as any).flushSync(() => {});
|
|
53
|
+
console.log('[onejs-react] flushSync completed');
|
|
54
|
+
} else {
|
|
55
|
+
console.log('[onejs-react] no sync flush method available, relying on microtasks');
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.log('[onejs-react] sync flush failed, relying on microtasks:', e);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function unmount(container: Container): void {
|
|
63
|
+
const root = roots.get(container);
|
|
64
|
+
if (root) {
|
|
65
|
+
reconciler.updateContainer(null, root, null, () => {});
|
|
66
|
+
roots.delete(container);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Export for testing/debugging
|
|
71
|
+
export function getRoot(container: Container) {
|
|
72
|
+
return roots.get(container);
|
|
73
|
+
}
|
package/src/screen.tsx
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responsive design system for OneJS
|
|
3
|
+
*
|
|
4
|
+
* Provides React context and hooks for responsive breakpoints.
|
|
5
|
+
* Uses event-driven updates via GeometryChangedEvent (not polling).
|
|
6
|
+
*
|
|
7
|
+
* Mobile-first: At 1400px width, sm/md/lg/xl are all active (not just xl).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createContext, useContext, useState, useEffect, ReactNode } from "react"
|
|
11
|
+
|
|
12
|
+
// Globals from QuickJS environment
|
|
13
|
+
declare const __root: {
|
|
14
|
+
__csHandle: number
|
|
15
|
+
resolvedStyle: {
|
|
16
|
+
width: number
|
|
17
|
+
height: number
|
|
18
|
+
}
|
|
19
|
+
AddToClassList: (className: string) => void
|
|
20
|
+
RemoveFromClassList: (className: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
declare const __eventAPI: {
|
|
24
|
+
addEventListener: (element: unknown, eventType: string, callback: Function) => void
|
|
25
|
+
removeEventListener: (element: unknown, eventType: string, callback: Function) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Breakpoint definitions (Tailwind v3 defaults)
|
|
29
|
+
export const BREAKPOINTS = {
|
|
30
|
+
sm: 640,
|
|
31
|
+
md: 768,
|
|
32
|
+
lg: 1024,
|
|
33
|
+
xl: 1280,
|
|
34
|
+
"2xl": 1536,
|
|
35
|
+
} as const
|
|
36
|
+
|
|
37
|
+
export type BreakpointName = keyof typeof BREAKPOINTS | "base"
|
|
38
|
+
|
|
39
|
+
export interface ScreenContextValue {
|
|
40
|
+
/** Current viewport width in pixels */
|
|
41
|
+
width: number
|
|
42
|
+
/** Current viewport height in pixels */
|
|
43
|
+
height: number
|
|
44
|
+
/** Current breakpoint name (highest matching) */
|
|
45
|
+
breakpoint: BreakpointName
|
|
46
|
+
/** True if viewport >= 640px */
|
|
47
|
+
isSm: boolean
|
|
48
|
+
/** True if viewport >= 768px */
|
|
49
|
+
isMd: boolean
|
|
50
|
+
/** True if viewport >= 1024px */
|
|
51
|
+
isLg: boolean
|
|
52
|
+
/** True if viewport >= 1280px */
|
|
53
|
+
isXl: boolean
|
|
54
|
+
/** True if viewport >= 1536px */
|
|
55
|
+
is2xl: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ScreenContext = createContext<ScreenContextValue | null>(null)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Calculate breakpoint state from viewport width
|
|
62
|
+
*/
|
|
63
|
+
function calculateBreakpoints(width: number, height: number): ScreenContextValue {
|
|
64
|
+
const isSm = width >= BREAKPOINTS.sm
|
|
65
|
+
const isMd = width >= BREAKPOINTS.md
|
|
66
|
+
const isLg = width >= BREAKPOINTS.lg
|
|
67
|
+
const isXl = width >= BREAKPOINTS.xl
|
|
68
|
+
const is2xl = width >= BREAKPOINTS["2xl"]
|
|
69
|
+
|
|
70
|
+
// Determine current breakpoint (highest matching)
|
|
71
|
+
let breakpoint: BreakpointName = "base"
|
|
72
|
+
if (is2xl) breakpoint = "2xl"
|
|
73
|
+
else if (isXl) breakpoint = "xl"
|
|
74
|
+
else if (isLg) breakpoint = "lg"
|
|
75
|
+
else if (isMd) breakpoint = "md"
|
|
76
|
+
else if (isSm) breakpoint = "sm"
|
|
77
|
+
|
|
78
|
+
return { width, height, breakpoint, isSm, isMd, isLg, isXl, is2xl }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Apply breakpoint classes to root element (mobile-first cascading)
|
|
83
|
+
*/
|
|
84
|
+
function applyBreakpointClasses(screen: ScreenContextValue) {
|
|
85
|
+
// Remove all breakpoint classes first
|
|
86
|
+
__root.RemoveFromClassList("sm")
|
|
87
|
+
__root.RemoveFromClassList("md")
|
|
88
|
+
__root.RemoveFromClassList("lg")
|
|
89
|
+
__root.RemoveFromClassList("xl")
|
|
90
|
+
__root.RemoveFromClassList("2xl")
|
|
91
|
+
|
|
92
|
+
// Mobile-first: apply ALL matching breakpoints, not just highest
|
|
93
|
+
if (screen.isSm) __root.AddToClassList("sm")
|
|
94
|
+
if (screen.isMd) __root.AddToClassList("md")
|
|
95
|
+
if (screen.isLg) __root.AddToClassList("lg")
|
|
96
|
+
if (screen.isXl) __root.AddToClassList("xl")
|
|
97
|
+
if (screen.is2xl) __root.AddToClassList("2xl")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ScreenProviderProps {
|
|
101
|
+
children: ReactNode
|
|
102
|
+
/** Custom breakpoints (optional) */
|
|
103
|
+
breakpoints?: Partial<typeof BREAKPOINTS>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Provider component for responsive screen context.
|
|
108
|
+
*
|
|
109
|
+
* Wrap your app with this to enable responsive hooks.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```tsx
|
|
113
|
+
* render(
|
|
114
|
+
* <ScreenProvider>
|
|
115
|
+
* <App />
|
|
116
|
+
* </ScreenProvider>,
|
|
117
|
+
* __root
|
|
118
|
+
* )
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export function ScreenProvider({ children }: ScreenProviderProps) {
|
|
122
|
+
// Initialize with current viewport size
|
|
123
|
+
const [screen, setScreen] = useState<ScreenContextValue>(() => {
|
|
124
|
+
const width = __root.resolvedStyle?.width || 0
|
|
125
|
+
const height = __root.resolvedStyle?.height || 0
|
|
126
|
+
return calculateBreakpoints(width, height)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
// Apply initial breakpoint classes
|
|
131
|
+
applyBreakpointClasses(screen)
|
|
132
|
+
|
|
133
|
+
// Handle viewport change events from C#
|
|
134
|
+
const handleViewportChange = (evt: { width: number; height: number }) => {
|
|
135
|
+
const newScreen = calculateBreakpoints(evt.width, evt.height)
|
|
136
|
+
setScreen(newScreen)
|
|
137
|
+
applyBreakpointClasses(newScreen)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Listen for viewport changes on root element
|
|
141
|
+
__eventAPI.addEventListener(__root, "viewportchange", handleViewportChange)
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
__eventAPI.removeEventListener(__root, "viewportchange", handleViewportChange)
|
|
145
|
+
}
|
|
146
|
+
}, [])
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<ScreenContext.Provider value={screen}>
|
|
150
|
+
{children}
|
|
151
|
+
</ScreenContext.Provider>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Hook to get the current breakpoint name.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```tsx
|
|
160
|
+
* function Component() {
|
|
161
|
+
* const breakpoint = useBreakpoint()
|
|
162
|
+
* return <Label text={`Current: ${breakpoint}`} />
|
|
163
|
+
* }
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
export function useBreakpoint(): BreakpointName {
|
|
167
|
+
const ctx = useContext(ScreenContext)
|
|
168
|
+
if (!ctx) {
|
|
169
|
+
throw new Error("useBreakpoint must be used within ScreenProvider")
|
|
170
|
+
}
|
|
171
|
+
return ctx.breakpoint
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Hook to get the current viewport size.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```tsx
|
|
179
|
+
* function Component() {
|
|
180
|
+
* const { width, height } = useScreenSize()
|
|
181
|
+
* return <Label text={`${width}x${height}`} />
|
|
182
|
+
* }
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
export function useScreenSize(): { width: number; height: number } {
|
|
186
|
+
const ctx = useContext(ScreenContext)
|
|
187
|
+
if (!ctx) {
|
|
188
|
+
throw new Error("useScreenSize must be used within ScreenProvider")
|
|
189
|
+
}
|
|
190
|
+
return { width: ctx.width, height: ctx.height }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Hook to get all responsive state.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```tsx
|
|
198
|
+
* function Component() {
|
|
199
|
+
* const { isMd, isLg, breakpoint } = useResponsive()
|
|
200
|
+
* return (
|
|
201
|
+
* <View>
|
|
202
|
+
* {isLg && <Sidebar />}
|
|
203
|
+
* <Content />
|
|
204
|
+
* </View>
|
|
205
|
+
* )
|
|
206
|
+
* }
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
export function useResponsive(): ScreenContextValue {
|
|
210
|
+
const ctx = useContext(ScreenContext)
|
|
211
|
+
if (!ctx) {
|
|
212
|
+
throw new Error("useResponsive must be used within ScreenProvider")
|
|
213
|
+
}
|
|
214
|
+
return ctx
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Hook to check if a specific breakpoint is active.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```tsx
|
|
222
|
+
* function Component() {
|
|
223
|
+
* const isDesktop = useMediaQuery("lg")
|
|
224
|
+
* return isDesktop ? <DesktopLayout /> : <MobileLayout />
|
|
225
|
+
* }
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
228
|
+
export function useMediaQuery(breakpoint: keyof typeof BREAKPOINTS): boolean {
|
|
229
|
+
const ctx = useContext(ScreenContext)
|
|
230
|
+
if (!ctx) {
|
|
231
|
+
throw new Error("useMediaQuery must be used within ScreenProvider")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
switch (breakpoint) {
|
|
235
|
+
case "sm": return ctx.isSm
|
|
236
|
+
case "md": return ctx.isMd
|
|
237
|
+
case "lg": return ctx.isLg
|
|
238
|
+
case "xl": return ctx.isXl
|
|
239
|
+
case "2xl": return ctx.is2xl
|
|
240
|
+
default: return false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Style value parsing utilities for OneJS React
|
|
3
|
+
*
|
|
4
|
+
* Converts friendly style values (numbers, strings like "100px", "#ff0000")
|
|
5
|
+
* into Unity UI Toolkit compatible values.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Unity UIElements types accessed via CS global
|
|
9
|
+
declare const CS: {
|
|
10
|
+
UnityEngine: {
|
|
11
|
+
Color: new (r: number, g: number, b: number, a: number) => CSColor;
|
|
12
|
+
UIElements: {
|
|
13
|
+
Length: new (value: number, unit?: number) => CSLength;
|
|
14
|
+
LengthUnit: { Pixel: number; Percent: number };
|
|
15
|
+
StyleKeyword: { Auto: number; None: number; Initial: number };
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
interface CSColor {
|
|
21
|
+
r: number;
|
|
22
|
+
g: number;
|
|
23
|
+
b: number;
|
|
24
|
+
a: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CSLength {
|
|
28
|
+
value: number;
|
|
29
|
+
unit: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Named CSS colors (common subset)
|
|
33
|
+
const NAMED_COLORS: Record<string, [number, number, number, number]> = {
|
|
34
|
+
transparent: [0, 0, 0, 0],
|
|
35
|
+
black: [0, 0, 0, 1],
|
|
36
|
+
white: [1, 1, 1, 1],
|
|
37
|
+
red: [1, 0, 0, 1],
|
|
38
|
+
green: [0, 0.502, 0, 1], // CSS green is #008000
|
|
39
|
+
blue: [0, 0, 1, 1],
|
|
40
|
+
yellow: [1, 1, 0, 1],
|
|
41
|
+
cyan: [0, 1, 1, 1],
|
|
42
|
+
magenta: [1, 0, 1, 1],
|
|
43
|
+
orange: [1, 0.647, 0, 1],
|
|
44
|
+
purple: [0.502, 0, 0.502, 1],
|
|
45
|
+
pink: [1, 0.753, 0.796, 1],
|
|
46
|
+
brown: [0.647, 0.165, 0.165, 1],
|
|
47
|
+
gray: [0.502, 0.502, 0.502, 1],
|
|
48
|
+
grey: [0.502, 0.502, 0.502, 1],
|
|
49
|
+
silver: [0.753, 0.753, 0.753, 1],
|
|
50
|
+
gold: [1, 0.843, 0, 1],
|
|
51
|
+
navy: [0, 0, 0.502, 1],
|
|
52
|
+
teal: [0, 0.502, 0.502, 1],
|
|
53
|
+
olive: [0.502, 0.502, 0, 1],
|
|
54
|
+
maroon: [0.502, 0, 0, 1],
|
|
55
|
+
aqua: [0, 1, 1, 1],
|
|
56
|
+
lime: [0, 1, 0, 1],
|
|
57
|
+
fuchsia: [1, 0, 1, 1],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Style properties that expect length values
|
|
61
|
+
const LENGTH_PROPERTIES = new Set([
|
|
62
|
+
"width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
|
|
63
|
+
"top", "right", "bottom", "left",
|
|
64
|
+
"margin", "marginTop", "marginRight", "marginBottom", "marginLeft",
|
|
65
|
+
"padding", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
|
|
66
|
+
"flexBasis",
|
|
67
|
+
"borderWidth", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
|
|
68
|
+
"borderRadius", "borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius",
|
|
69
|
+
"fontSize",
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
// Style properties that expect color values
|
|
73
|
+
const COLOR_PROPERTIES = new Set([
|
|
74
|
+
"color", "backgroundColor",
|
|
75
|
+
"borderColor", "borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor",
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
// Style properties that are plain numbers (no Length wrapper needed)
|
|
79
|
+
const NUMBER_PROPERTIES = new Set([
|
|
80
|
+
"flexGrow", "flexShrink", "opacity",
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
// Enum properties - these get passed through as-is (C# handles conversion)
|
|
84
|
+
const ENUM_PROPERTIES = new Set([
|
|
85
|
+
"flexDirection", "flexWrap", "alignItems", "alignSelf", "alignContent", "justifyContent",
|
|
86
|
+
"position", "overflow", "display", "visibility", "whiteSpace",
|
|
87
|
+
"unityTextAlign", "fontStyle",
|
|
88
|
+
])
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse a length value from various formats
|
|
92
|
+
* @param value - number, "100", "100px", "50%", "auto"
|
|
93
|
+
* @returns Unity Length struct or StyleKeyword
|
|
94
|
+
*/
|
|
95
|
+
export function parseLength(value: number | string): CSLength | number | null {
|
|
96
|
+
if (typeof value === "number") {
|
|
97
|
+
return new CS.UnityEngine.UIElements.Length(value, CS.UnityEngine.UIElements.LengthUnit.Pixel)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typeof value !== "string") return null
|
|
101
|
+
|
|
102
|
+
const trimmed = value.trim().toLowerCase()
|
|
103
|
+
|
|
104
|
+
// Handle keywords
|
|
105
|
+
if (trimmed === "auto") {
|
|
106
|
+
return CS.UnityEngine.UIElements.StyleKeyword.Auto
|
|
107
|
+
}
|
|
108
|
+
if (trimmed === "none") {
|
|
109
|
+
return CS.UnityEngine.UIElements.StyleKeyword.None
|
|
110
|
+
}
|
|
111
|
+
if (trimmed === "initial") {
|
|
112
|
+
return CS.UnityEngine.UIElements.StyleKeyword.Initial
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Parse numeric values with units
|
|
116
|
+
const match = trimmed.match(/^(-?[\d.]+)(px|%)?$/)
|
|
117
|
+
if (match) {
|
|
118
|
+
const num = parseFloat(match[1])
|
|
119
|
+
if (isNaN(num)) return null
|
|
120
|
+
|
|
121
|
+
const unitStr = match[2]
|
|
122
|
+
const unit = unitStr === "%"
|
|
123
|
+
? CS.UnityEngine.UIElements.LengthUnit.Percent
|
|
124
|
+
: CS.UnityEngine.UIElements.LengthUnit.Pixel
|
|
125
|
+
|
|
126
|
+
return new CS.UnityEngine.UIElements.Length(num, unit)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse a hex color component (1 or 2 characters)
|
|
134
|
+
*/
|
|
135
|
+
function parseHexComponent(hex: string): number {
|
|
136
|
+
if (hex.length === 1) {
|
|
137
|
+
hex = hex + hex // Expand shorthand: "f" -> "ff"
|
|
138
|
+
}
|
|
139
|
+
return parseInt(hex, 16) / 255
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse a color value from various formats
|
|
144
|
+
* @param value - "#fff", "#ffffff", "#ffffffff", "rgb(255,0,0)", "rgba(255,0,0,0.5)", "red"
|
|
145
|
+
* @returns Unity Color struct or null if invalid
|
|
146
|
+
*/
|
|
147
|
+
export function parseColor(value: string): CSColor | null {
|
|
148
|
+
if (typeof value !== "string") return null
|
|
149
|
+
|
|
150
|
+
const trimmed = value.trim().toLowerCase()
|
|
151
|
+
|
|
152
|
+
// Named colors
|
|
153
|
+
if (NAMED_COLORS[trimmed]) {
|
|
154
|
+
const [r, g, b, a] = NAMED_COLORS[trimmed]
|
|
155
|
+
return new CS.UnityEngine.Color(r, g, b, a)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Hex colors: #rgb, #rgba, #rrggbb, #rrggbbaa
|
|
159
|
+
if (trimmed.startsWith("#")) {
|
|
160
|
+
const hex = trimmed.slice(1)
|
|
161
|
+
|
|
162
|
+
if (hex.length === 3) {
|
|
163
|
+
// #rgb
|
|
164
|
+
const r = parseHexComponent(hex[0])
|
|
165
|
+
const g = parseHexComponent(hex[1])
|
|
166
|
+
const b = parseHexComponent(hex[2])
|
|
167
|
+
return new CS.UnityEngine.Color(r, g, b, 1)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (hex.length === 4) {
|
|
171
|
+
// #rgba
|
|
172
|
+
const r = parseHexComponent(hex[0])
|
|
173
|
+
const g = parseHexComponent(hex[1])
|
|
174
|
+
const b = parseHexComponent(hex[2])
|
|
175
|
+
const a = parseHexComponent(hex[3])
|
|
176
|
+
return new CS.UnityEngine.Color(r, g, b, a)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (hex.length === 6) {
|
|
180
|
+
// #rrggbb
|
|
181
|
+
const r = parseHexComponent(hex.slice(0, 2))
|
|
182
|
+
const g = parseHexComponent(hex.slice(2, 4))
|
|
183
|
+
const b = parseHexComponent(hex.slice(4, 6))
|
|
184
|
+
return new CS.UnityEngine.Color(r, g, b, 1)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (hex.length === 8) {
|
|
188
|
+
// #rrggbbaa
|
|
189
|
+
const r = parseHexComponent(hex.slice(0, 2))
|
|
190
|
+
const g = parseHexComponent(hex.slice(2, 4))
|
|
191
|
+
const b = parseHexComponent(hex.slice(4, 6))
|
|
192
|
+
const a = parseHexComponent(hex.slice(6, 8))
|
|
193
|
+
return new CS.UnityEngine.Color(r, g, b, a)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// rgb(r, g, b) or rgba(r, g, b, a)
|
|
200
|
+
const rgbMatch = trimmed.match(/^rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/)
|
|
201
|
+
if (rgbMatch) {
|
|
202
|
+
const r = parseInt(rgbMatch[1]) / 255
|
|
203
|
+
const g = parseInt(rgbMatch[2]) / 255
|
|
204
|
+
const b = parseInt(rgbMatch[3]) / 255
|
|
205
|
+
const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) : 1
|
|
206
|
+
return new CS.UnityEngine.Color(
|
|
207
|
+
Math.min(1, Math.max(0, r)),
|
|
208
|
+
Math.min(1, Math.max(0, g)),
|
|
209
|
+
Math.min(1, Math.max(0, b)),
|
|
210
|
+
Math.min(1, Math.max(0, a))
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// rgb with percentages: rgb(100%, 0%, 0%)
|
|
215
|
+
const rgbPercentMatch = trimmed.match(/^rgba?\s*\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+))?\s*\)$/)
|
|
216
|
+
if (rgbPercentMatch) {
|
|
217
|
+
const r = parseFloat(rgbPercentMatch[1]) / 100
|
|
218
|
+
const g = parseFloat(rgbPercentMatch[2]) / 100
|
|
219
|
+
const b = parseFloat(rgbPercentMatch[3]) / 100
|
|
220
|
+
const a = rgbPercentMatch[4] !== undefined ? parseFloat(rgbPercentMatch[4]) : 1
|
|
221
|
+
return new CS.UnityEngine.Color(
|
|
222
|
+
Math.min(1, Math.max(0, r)),
|
|
223
|
+
Math.min(1, Math.max(0, g)),
|
|
224
|
+
Math.min(1, Math.max(0, b)),
|
|
225
|
+
Math.min(1, Math.max(0, a))
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Parse a style value based on the property name
|
|
234
|
+
* @param key - Style property name (e.g., "width", "backgroundColor")
|
|
235
|
+
* @param value - Raw value from React style object
|
|
236
|
+
* @returns Parsed value suitable for Unity UI Toolkit
|
|
237
|
+
*/
|
|
238
|
+
export function parseStyleValue(key: string, value: unknown): unknown {
|
|
239
|
+
if (value === undefined || value === null) return value
|
|
240
|
+
|
|
241
|
+
// Length properties
|
|
242
|
+
if (LENGTH_PROPERTIES.has(key)) {
|
|
243
|
+
if (typeof value === "number") {
|
|
244
|
+
return new CS.UnityEngine.UIElements.Length(value, CS.UnityEngine.UIElements.LengthUnit.Pixel)
|
|
245
|
+
}
|
|
246
|
+
if (typeof value === "string") {
|
|
247
|
+
const parsed = parseLength(value)
|
|
248
|
+
if (parsed !== null) return parsed
|
|
249
|
+
}
|
|
250
|
+
// Fall through to return original value
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Color properties
|
|
254
|
+
if (COLOR_PROPERTIES.has(key)) {
|
|
255
|
+
if (typeof value === "string") {
|
|
256
|
+
const parsed = parseColor(value)
|
|
257
|
+
if (parsed !== null) return parsed
|
|
258
|
+
}
|
|
259
|
+
// Could already be a Color object, pass through
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Plain number properties - pass through as-is
|
|
263
|
+
if (NUMBER_PROPERTIES.has(key)) {
|
|
264
|
+
return value
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Enum properties - pass through as-is (C# handles string -> enum)
|
|
268
|
+
if (ENUM_PROPERTIES.has(key)) {
|
|
269
|
+
return value
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Unknown property - pass through unchanged
|
|
273
|
+
return value
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Check if a property is a length property
|
|
278
|
+
*/
|
|
279
|
+
export function isLengthProperty(key: string): boolean {
|
|
280
|
+
return LENGTH_PROPERTIES.has(key)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Check if a property is a color property
|
|
285
|
+
*/
|
|
286
|
+
export function isColorProperty(key: string): boolean {
|
|
287
|
+
return COLOR_PROPERTIES.has(key)
|
|
288
|
+
}
|