exodeui-react-native 1.0.0 → 1.0.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.
- package/index.js +1 -0
- package/package.json +10 -7
- package/src/ExodeUIView.tsx +127 -32
- package/src/__tests__/engine.test.ts +299 -0
- package/src/engine.ts +1430 -277
- package/src/physics/MatterPhysics.ts +66 -0
- package/src/physics/PhysicsEngine.ts +42 -0
- package/src/physics/index.ts +2 -0
- package/src/types.ts +160 -16
- package/src/useExodeUI.ts +31 -1
package/src/engine.ts
CHANGED
|
@@ -1,32 +1,55 @@
|
|
|
1
|
-
import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter } from '@shopify/react-native-skia';
|
|
2
|
-
import { Artboard, Animation, ShapeObject, StateMachine, State, Fit, Alignment, Layout } from './types';
|
|
3
|
-
import
|
|
1
|
+
import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter, ClipOp, matchFont, FontStyle, SkFont } from '@shopify/react-native-skia';
|
|
2
|
+
import { Artboard, Animation as SDKAnimation, ShapeObject, StateMachine, State, Fit, Alignment, Layout, LogicNode, LogicOp, Constraint, ComponentEvent } from './types';
|
|
3
|
+
import { PhysicsEngine, MatterPhysics } from './physics';
|
|
4
4
|
|
|
5
5
|
export class ExodeUIEngine {
|
|
6
6
|
private artboard: Artboard | null = null;
|
|
7
7
|
private objectStates: Map<string, any> = new Map();
|
|
8
8
|
|
|
9
9
|
// Physics State
|
|
10
|
-
private physicsEngine:
|
|
11
|
-
private physicsBodies: Map<string, Matter.Body> = new Map();
|
|
10
|
+
private physicsEngine: PhysicsEngine | null = null;
|
|
12
11
|
|
|
13
12
|
// State Machine State
|
|
14
13
|
private activeStateMachine: StateMachine | null = null;
|
|
14
|
+
private _renderCount: number = 0;
|
|
15
15
|
private inputs: Map<string, any> = new Map(); // id -> value
|
|
16
16
|
private inputNameMap: Map<string, string[]> = new Map(); // name -> id[] mapping
|
|
17
17
|
private layerStates: Map<string, {
|
|
18
18
|
currentStateIds: string[];
|
|
19
|
-
animation:
|
|
19
|
+
animations: { animation: SDKAnimation; state: State | null }[];
|
|
20
20
|
time: number;
|
|
21
|
-
|
|
21
|
+
duration: number;
|
|
22
22
|
}> = new Map();
|
|
23
23
|
|
|
24
24
|
private imageCache = new Map<string, SkImage>();
|
|
25
|
+
private fonts = new Map<number, SkFont>();
|
|
25
26
|
|
|
26
27
|
private layout: Layout = { fit: 'Contain', alignment: 'Center' };
|
|
27
28
|
|
|
28
29
|
private onTrigger?: (triggerName: string, animationName: string) => void;
|
|
29
30
|
private onInputUpdate?: (nameOrId: string, value: any) => void;
|
|
31
|
+
private onComponentChange?: (event: ComponentEvent) => void;
|
|
32
|
+
|
|
33
|
+
// Specific Component Listeners
|
|
34
|
+
private onToggle?: (name: string, checked: boolean) => void;
|
|
35
|
+
private onInputChange?: (name: string, text: string) => void;
|
|
36
|
+
private onInputFocus?: (name: string) => void;
|
|
37
|
+
private onInputBlur?: (name: string) => void;
|
|
38
|
+
|
|
39
|
+
// Track triggers that were just fired in the current frame
|
|
40
|
+
private justFiredTriggers: Set<string> = new Set();
|
|
41
|
+
private _lastPointerPos: { x: number; y: number } | null = null;
|
|
42
|
+
private _prevPointerPos: { x: number; y: number } | null = null;
|
|
43
|
+
|
|
44
|
+
// Interaction State
|
|
45
|
+
private focusedId: string | null = null;
|
|
46
|
+
private draggingSliderId: string | null = null;
|
|
47
|
+
private activeDropdownId: string | null = null;
|
|
48
|
+
private draggingListViewId: string | null = null;
|
|
49
|
+
private lastHoveredObjectId: string | null = null;
|
|
50
|
+
|
|
51
|
+
// Physics/Animation State
|
|
52
|
+
private objectVelocities: Map<string, { vx: number; vy: number }> = new Map();
|
|
30
53
|
|
|
31
54
|
setTriggerCallback(cb: (triggerName: string, animationName: string) => void) {
|
|
32
55
|
this.onTrigger = cb;
|
|
@@ -36,6 +59,26 @@ export class ExodeUIEngine {
|
|
|
36
59
|
this.onInputUpdate = cb;
|
|
37
60
|
}
|
|
38
61
|
|
|
62
|
+
setComponentChangeCallback(cb: (event: ComponentEvent) => void) {
|
|
63
|
+
this.onComponentChange = cb;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setToggleCallback(cb: (name: string, checked: boolean) => void) {
|
|
67
|
+
this.onToggle = cb;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setInputChangeCallback(cb: (name: string, text: string) => void) {
|
|
71
|
+
this.onInputChange = cb;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setInputFocusCallback(cb: (name: string) => void) {
|
|
75
|
+
this.onInputFocus = cb;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setInputBlurCallback(cb: (name: string) => void) {
|
|
79
|
+
this.onInputBlur = cb;
|
|
80
|
+
}
|
|
81
|
+
|
|
39
82
|
constructor() {}
|
|
40
83
|
|
|
41
84
|
// Helper to update Map AND notify listener
|
|
@@ -55,81 +98,156 @@ export class ExodeUIEngine {
|
|
|
55
98
|
return state ? state.currentStateIds : [];
|
|
56
99
|
}
|
|
57
100
|
|
|
101
|
+
getObjectState(id: string): any {
|
|
102
|
+
return this.objectStates.get(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
updateConstraint(objectId: string, index: number, properties: Partial<Constraint>) {
|
|
107
|
+
const obj = this.artboard?.objects.find(o => o.id === objectId);
|
|
108
|
+
if (obj && obj.constraints && obj.constraints[index]) {
|
|
109
|
+
obj.constraints[index] = { ...obj.constraints[index], ...properties } as any;
|
|
110
|
+
|
|
111
|
+
// Clear velocity if spring is disabled or reset
|
|
112
|
+
if (properties.useSpring === false) {
|
|
113
|
+
this.objectVelocities.delete(objectId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
updateObjectOptions(id: string, newOptions: any) {
|
|
119
|
+
const state = this.objectStates.get(id);
|
|
120
|
+
if (state) {
|
|
121
|
+
state.options = { ...state.options, ...newOptions };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public updateGraphData(nameOrId: string, data: number[]) {
|
|
126
|
+
if (!this.artboard) return;
|
|
127
|
+
|
|
128
|
+
// Find the graph object
|
|
129
|
+
const obj = this.artboard.objects.find(o => o.id === nameOrId || o.name === nameOrId);
|
|
130
|
+
if (obj && obj.geometry.type === 'LineGraph') {
|
|
131
|
+
const geo = obj.geometry as any;
|
|
132
|
+
|
|
133
|
+
if (!geo.datasets || geo.datasets.length === 0) {
|
|
134
|
+
geo.datasets = [{
|
|
135
|
+
id: 'default',
|
|
136
|
+
label: 'Runtime Data',
|
|
137
|
+
data: data,
|
|
138
|
+
lineColor: '#3b82f6',
|
|
139
|
+
lineWidth: 2,
|
|
140
|
+
showArea: false
|
|
141
|
+
}];
|
|
142
|
+
} else {
|
|
143
|
+
geo.datasets[0].data = data;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const state = this.objectStates.get(obj.id);
|
|
147
|
+
if (state && state.geometry && state.geometry.type === 'LineGraph') {
|
|
148
|
+
if (!state.geometry.datasets || state.geometry.datasets.length === 0) {
|
|
149
|
+
state.geometry.datasets = JSON.parse(JSON.stringify(geo.datasets));
|
|
150
|
+
} else {
|
|
151
|
+
state.geometry.datasets[0].data = [...data];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (geo.datasets[0].inputId) {
|
|
156
|
+
this.updateInput(geo.datasets[0].inputId, data);
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
this.updateInput(nameOrId, data);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
58
163
|
load(data: Artboard) {
|
|
164
|
+
if (!data) {
|
|
165
|
+
console.warn('[ExodeUIEngine] Attempted to load null artboard');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
console.log(`[ExodeUIEngine] Loading artboard: ${data.name} (${data.width}x${data.height})`);
|
|
59
169
|
this.artboard = data;
|
|
60
170
|
this.reset();
|
|
171
|
+
this.advance(0);
|
|
61
172
|
}
|
|
62
173
|
|
|
63
174
|
reset() {
|
|
64
175
|
if (!this.artboard) return;
|
|
65
176
|
this.objectStates.clear();
|
|
177
|
+
this.objectVelocities.clear();
|
|
66
178
|
this.inputs.clear();
|
|
67
179
|
this.inputNameMap.clear();
|
|
68
180
|
this.layerStates.clear();
|
|
69
|
-
this.
|
|
70
|
-
|
|
181
|
+
this.fonts.clear();
|
|
182
|
+
|
|
183
|
+
if (this.physicsEngine) {
|
|
184
|
+
this.physicsEngine.destroy();
|
|
185
|
+
this.physicsEngine = null;
|
|
186
|
+
}
|
|
71
187
|
|
|
72
188
|
// Initialize object states
|
|
73
|
-
this.artboard.objects
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
this.artboard.objects.forEach(obj => {
|
|
96
|
-
if (obj.physics?.enabled && obj.type === 'Shape') {
|
|
97
|
-
const state = this.objectStates.get(obj.id);
|
|
98
|
-
const w = (obj.geometry as any).width || 100;
|
|
99
|
-
const h = (obj.geometry as any).height || 100;
|
|
100
|
-
|
|
101
|
-
const options: Matter.IChamferableBodyDefinition = {
|
|
102
|
-
isStatic: obj.physics.bodyType === 'Static',
|
|
103
|
-
label: obj.id,
|
|
104
|
-
friction: obj.physics.friction,
|
|
105
|
-
restitution: obj.physics.restitution,
|
|
106
|
-
mass: obj.physics.mass,
|
|
107
|
-
frictionAir: obj.physics.frictionAir,
|
|
108
|
-
isSensor: obj.physics.isSensor,
|
|
109
|
-
angle: state.rotation * (Math.PI / 180) // Convert deg to rad
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
if (obj.physics.density) {
|
|
113
|
-
options.density = obj.physics.density;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
let body: Matter.Body | null = null;
|
|
117
|
-
|
|
118
|
-
if (obj.geometry.type === 'Rectangle' || obj.geometry.type === 'Image') {
|
|
119
|
-
body = Matter.Bodies.rectangle(state.x, state.y, w, h, options);
|
|
120
|
-
} else if (obj.geometry.type === 'Ellipse') {
|
|
121
|
-
const r = (w + h) / 4;
|
|
122
|
-
body = Matter.Bodies.circle(state.x, state.y, r, options);
|
|
123
|
-
} else {
|
|
124
|
-
body = Matter.Bodies.rectangle(state.x, state.y, w, h, options);
|
|
125
|
-
}
|
|
189
|
+
if (this.artboard.objects) {
|
|
190
|
+
this.artboard.objects?.forEach((obj: ShapeObject) => {
|
|
191
|
+
// Deep copy initial state
|
|
192
|
+
const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
|
|
193
|
+
this.objectStates.set(obj.id, {
|
|
194
|
+
x: transform.x ?? 0,
|
|
195
|
+
y: transform.y ?? 0,
|
|
196
|
+
rotation: transform.rotation ?? 0,
|
|
197
|
+
scale_x: transform.scale_x ?? 1,
|
|
198
|
+
scale_y: transform.scale_y ?? 1,
|
|
199
|
+
width: (obj as any).width || (obj.geometry as any).width || 100,
|
|
200
|
+
height: (obj as any).height || (obj.geometry as any).height || 100,
|
|
201
|
+
cornerRadius: (obj as any).cornerRadius ?? (obj as any).corner_radius ?? 0,
|
|
202
|
+
opacity: obj.opacity !== undefined ? obj.opacity : 1,
|
|
203
|
+
visible: obj.visible !== undefined ? obj.visible : (obj.isVisible !== undefined ? obj.isVisible : true),
|
|
204
|
+
blendMode: obj.blendMode || 'Normal',
|
|
205
|
+
style: JSON.parse(JSON.stringify(obj.style || {})),
|
|
206
|
+
geometry: JSON.parse(JSON.stringify(obj.geometry || {})),
|
|
207
|
+
options: (obj as any).options ? JSON.parse(JSON.stringify((obj as any).options)) : {}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
126
210
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
211
|
+
// Initialize Physics Engine if any object has physics enabled
|
|
212
|
+
const hasPhysics = this.artboard.objects.some(obj => obj.physics?.enabled);
|
|
213
|
+
if (hasPhysics) {
|
|
214
|
+
this.physicsEngine = new MatterPhysics();
|
|
215
|
+
|
|
216
|
+
const gravity = this.artboard.physics
|
|
217
|
+
? { x: this.artboard.physics.gravity.x, y: this.artboard.physics.gravity.y }
|
|
218
|
+
: { x: 0, y: 1 }; // Default
|
|
219
|
+
|
|
220
|
+
this.physicsEngine.init(gravity).then(() => {
|
|
221
|
+
if (!this.physicsEngine) return;
|
|
222
|
+
|
|
223
|
+
this.artboard?.objects?.forEach(obj => {
|
|
224
|
+
if (obj.physics?.enabled && obj.type === 'Shape') {
|
|
225
|
+
const state = this.objectStates.get(obj.id);
|
|
226
|
+
if (!state) return;
|
|
227
|
+
|
|
228
|
+
const w = (obj.geometry as any).width || 100;
|
|
229
|
+
const h = (obj.geometry as any).height || 100;
|
|
230
|
+
|
|
231
|
+
this.physicsEngine!.createBody({
|
|
232
|
+
id: obj.id,
|
|
233
|
+
type: obj.geometry.type as any,
|
|
234
|
+
x: state.x,
|
|
235
|
+
y: state.y,
|
|
236
|
+
width: w,
|
|
237
|
+
height: h,
|
|
238
|
+
rotation: state.rotation * (Math.PI / 180),
|
|
239
|
+
isStatic: obj.physics.bodyType === 'Static',
|
|
240
|
+
mass: obj.physics.mass,
|
|
241
|
+
friction: obj.physics.friction,
|
|
242
|
+
restitution: obj.physics.restitution,
|
|
243
|
+
frictionAir: obj.physics.frictionAir,
|
|
244
|
+
density: obj.physics.density,
|
|
245
|
+
isSensor: obj.physics.isSensor
|
|
246
|
+
});
|
|
130
247
|
}
|
|
131
|
-
}
|
|
132
|
-
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
133
251
|
}
|
|
134
252
|
|
|
135
253
|
// Initialize State Machine
|
|
@@ -137,7 +255,7 @@ export class ExodeUIEngine {
|
|
|
137
255
|
this.activeStateMachine = this.artboard.stateMachine;
|
|
138
256
|
|
|
139
257
|
// Init Inputs
|
|
140
|
-
this.activeStateMachine.inputs
|
|
258
|
+
this.activeStateMachine.inputs?.forEach((input: any) => {
|
|
141
259
|
this.inputs.set(input.id, input.value.value);
|
|
142
260
|
|
|
143
261
|
// Map Name -> IDs
|
|
@@ -147,7 +265,7 @@ export class ExodeUIEngine {
|
|
|
147
265
|
});
|
|
148
266
|
|
|
149
267
|
// Init Layers
|
|
150
|
-
this.activeStateMachine.layers
|
|
268
|
+
this.activeStateMachine.layers?.forEach((layer: any) => {
|
|
151
269
|
// Initial Entry
|
|
152
270
|
const entryState = layer.states.find((s: State) => s.id === layer.entryStateId);
|
|
153
271
|
|
|
@@ -155,17 +273,18 @@ export class ExodeUIEngine {
|
|
|
155
273
|
this.enterStates(layer.name, [entryState.id]);
|
|
156
274
|
}
|
|
157
275
|
});
|
|
276
|
+
|
|
277
|
+
this.evaluateTransitions();
|
|
158
278
|
}
|
|
159
279
|
|
|
160
280
|
if (!this.artboard.stateMachine && this.artboard.animations.length > 0) {
|
|
161
|
-
|
|
162
|
-
const onLoadAnim = this.artboard.animations.find((a: Animation) => a.name === 'onLoad');
|
|
281
|
+
const onLoadAnim = this.artboard.animations.find((a: SDKAnimation) => a.name === 'onLoad');
|
|
163
282
|
if (onLoadAnim) {
|
|
164
283
|
this.layerStates.set('intro', {
|
|
165
284
|
currentStateIds: ['onLoad'],
|
|
166
|
-
animation: onLoadAnim,
|
|
285
|
+
animations: [{ animation: onLoadAnim, state: { loop: true } as any }],
|
|
167
286
|
time: 0,
|
|
168
|
-
|
|
287
|
+
duration: onLoadAnim.duration
|
|
169
288
|
});
|
|
170
289
|
}
|
|
171
290
|
}
|
|
@@ -174,30 +293,26 @@ export class ExodeUIEngine {
|
|
|
174
293
|
private enterStates(layerName: string, stateIds: string[]) {
|
|
175
294
|
if (stateIds.length === 0) return;
|
|
176
295
|
|
|
177
|
-
|
|
178
|
-
let
|
|
296
|
+
const activeAnims: { animation: SDKAnimation; state: State | null }[] = [];
|
|
297
|
+
let maxDuration = 0;
|
|
179
298
|
|
|
180
299
|
if (this.artboard && this.activeStateMachine) {
|
|
181
300
|
const layer = this.activeStateMachine.layers.find(l => l.name === layerName);
|
|
182
301
|
if (layer) {
|
|
183
|
-
|
|
184
|
-
for (let i = stateIds.length - 1; i >= 0; i--) {
|
|
185
|
-
const sId = stateIds[i];
|
|
302
|
+
for (const sId of stateIds) {
|
|
186
303
|
const state = layer.states.find(s => s.id === sId);
|
|
187
|
-
|
|
188
304
|
if (state) {
|
|
189
|
-
|
|
305
|
+
let anim: SDKAnimation | null = null;
|
|
190
306
|
if (state.animationId) {
|
|
191
307
|
anim = this.artboard.animations.find(a => a.id === state.animationId) || null;
|
|
192
308
|
}
|
|
193
|
-
// Priority 2: Name Match (Legacy)
|
|
194
309
|
if (!anim) {
|
|
195
|
-
|
|
310
|
+
anim = this.artboard.animations.find((a: SDKAnimation) => a.name === state.name || a.id === state.name) || null;
|
|
196
311
|
}
|
|
197
312
|
|
|
198
313
|
if (anim) {
|
|
199
|
-
|
|
200
|
-
|
|
314
|
+
activeAnims.push({ animation: anim, state });
|
|
315
|
+
maxDuration = Math.max(maxDuration, anim.duration);
|
|
201
316
|
}
|
|
202
317
|
}
|
|
203
318
|
}
|
|
@@ -206,9 +321,9 @@ export class ExodeUIEngine {
|
|
|
206
321
|
|
|
207
322
|
this.layerStates.set(layerName, {
|
|
208
323
|
currentStateIds: stateIds,
|
|
209
|
-
|
|
324
|
+
animations: activeAnims,
|
|
210
325
|
time: 0,
|
|
211
|
-
|
|
326
|
+
duration: maxDuration
|
|
212
327
|
});
|
|
213
328
|
}
|
|
214
329
|
|
|
@@ -229,11 +344,17 @@ export class ExodeUIEngine {
|
|
|
229
344
|
this.updateInput(nameOrId, value);
|
|
230
345
|
}
|
|
231
346
|
|
|
347
|
+
setInputNumberArray(nameOrId: string, value: number[]) {
|
|
348
|
+
this.updateInput(nameOrId, value);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
setInputStringArray(nameOrId: string, value: string[]) {
|
|
352
|
+
this.updateInput(nameOrId, value);
|
|
353
|
+
}
|
|
354
|
+
|
|
232
355
|
private updateInput(nameOrId: string, value: any) {
|
|
233
|
-
console.log(`[Engine] updateInput: ${nameOrId} -> ${value}`);
|
|
234
356
|
|
|
235
357
|
let inputType: string | undefined;
|
|
236
|
-
// Resolve Type
|
|
237
358
|
if (this.inputs.has(nameOrId)) {
|
|
238
359
|
const input = this.activeStateMachine?.inputs.find(i => i.id === nameOrId);
|
|
239
360
|
if (input && typeof input.value === 'object') {
|
|
@@ -255,42 +376,36 @@ export class ExodeUIEngine {
|
|
|
255
376
|
else if (value === 0) finalValue = false;
|
|
256
377
|
}
|
|
257
378
|
|
|
258
|
-
// 1. Try treating as ID
|
|
259
379
|
if (this.inputs.has(nameOrId)) {
|
|
260
380
|
this.setInternalInput(nameOrId, finalValue);
|
|
261
381
|
}
|
|
262
382
|
|
|
263
|
-
// 2. Try treating as Name (Broadcast)
|
|
264
383
|
const ids = this.inputNameMap.get(nameOrId);
|
|
265
384
|
if (ids) {
|
|
266
|
-
ids
|
|
385
|
+
ids?.forEach(id => this.setInternalInput(id, finalValue));
|
|
267
386
|
}
|
|
268
387
|
|
|
269
|
-
// TRIGGER HARD RESET logic
|
|
270
388
|
if (inputType === 'Trigger' && finalValue === true) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
389
|
+
this.justFiredTriggers.add(nameOrId);
|
|
390
|
+
if (ids) {
|
|
391
|
+
ids?.forEach(id => this.justFiredTriggers.add(id));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Sync with compatible components (ListView/Dropdown)
|
|
396
|
+
if (Array.isArray(finalValue)) {
|
|
397
|
+
this.artboard?.objects?.forEach(obj => {
|
|
398
|
+
if (obj.inputId === nameOrId || obj.name === nameOrId) {
|
|
399
|
+
const state = this.objectStates.get(obj.id);
|
|
400
|
+
if (state && state.options) {
|
|
401
|
+
if (obj.type === 'ListView' || (obj as any).variant === 'listview') {
|
|
402
|
+
state.options.items = finalValue;
|
|
403
|
+
} else if (obj.type === 'Dropdown' || (obj as any).variant === 'dropdown') {
|
|
404
|
+
state.options.optionsList = finalValue;
|
|
289
405
|
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
292
408
|
});
|
|
293
|
-
return;
|
|
294
409
|
}
|
|
295
410
|
|
|
296
411
|
this.evaluateTransitions();
|
|
@@ -299,7 +414,7 @@ export class ExodeUIEngine {
|
|
|
299
414
|
private evaluateTransitions() {
|
|
300
415
|
if (!this.activeStateMachine) return;
|
|
301
416
|
|
|
302
|
-
this.activeStateMachine.layers
|
|
417
|
+
this.activeStateMachine.layers?.forEach((layer) => {
|
|
303
418
|
const layerState = this.layerStates.get(layer.name);
|
|
304
419
|
if (!layerState || layerState.currentStateIds.length === 0) return;
|
|
305
420
|
|
|
@@ -318,19 +433,12 @@ export class ExodeUIEngine {
|
|
|
318
433
|
transitioned = true;
|
|
319
434
|
hasTransition = true;
|
|
320
435
|
|
|
321
|
-
// Reset Trigger/Number inputs used in this transition
|
|
322
436
|
if (trans.conditions) {
|
|
323
|
-
trans.conditions
|
|
437
|
+
trans.conditions?.forEach((cond: any) => {
|
|
324
438
|
const input = this.activeStateMachine?.inputs.find(i => i.id === cond.inputId);
|
|
325
439
|
if (input && typeof input.value === 'object') {
|
|
326
|
-
if (input.value.type === '
|
|
327
|
-
|
|
328
|
-
if (this.inputNameMap.has(input.name)) {
|
|
329
|
-
const ids = this.inputNameMap.get(input.name);
|
|
330
|
-
ids?.forEach(id => this.setInternalInput(id, false));
|
|
331
|
-
}
|
|
332
|
-
} else if (input.value.type === 'Number') {
|
|
333
|
-
this.setInternalInput(cond.inputId, 0);
|
|
440
|
+
if (input.value.type === 'Number') {
|
|
441
|
+
this.setInternalInput(cond.inputId, 0);
|
|
334
442
|
if (this.inputNameMap.has(input.name)) {
|
|
335
443
|
const ids = this.inputNameMap.get(input.name);
|
|
336
444
|
ids?.forEach(id => this.setInternalInput(id, 0));
|
|
@@ -347,11 +455,63 @@ export class ExodeUIEngine {
|
|
|
347
455
|
}
|
|
348
456
|
}
|
|
349
457
|
|
|
458
|
+
if (!hasTransition) {
|
|
459
|
+
const globalTransition = this.findGlobalTransition(layer, layerState.currentStateIds);
|
|
460
|
+
if (globalTransition) {
|
|
461
|
+
nextStateIds.length = 0;
|
|
462
|
+
nextStateIds.push(globalTransition.targetStateId);
|
|
463
|
+
hasTransition = true;
|
|
464
|
+
|
|
465
|
+
if (globalTransition.conditions) {
|
|
466
|
+
globalTransition.conditions?.forEach((cond: any) => {
|
|
467
|
+
const input = this.activeStateMachine?.inputs.find(i => i.id === cond.inputId);
|
|
468
|
+
if (input && typeof input.value === 'object') {
|
|
469
|
+
if (input.value.type === 'Number') {
|
|
470
|
+
this.setInternalInput(cond.inputId, 0);
|
|
471
|
+
if (this.inputNameMap.has(input.name)) {
|
|
472
|
+
const ids = this.inputNameMap.get(input.name);
|
|
473
|
+
ids?.forEach(id => this.setInternalInput(id, 0));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
350
482
|
if (hasTransition) {
|
|
351
483
|
const uniqueIds = Array.from(new Set(nextStateIds));
|
|
352
484
|
this.enterStates(layer.name, uniqueIds);
|
|
353
485
|
}
|
|
354
486
|
});
|
|
487
|
+
|
|
488
|
+
if (this.justFiredTriggers.size > 0) {
|
|
489
|
+
this.justFiredTriggers?.forEach(id => {
|
|
490
|
+
this.setInternalInput(id, false);
|
|
491
|
+
const input = this.activeStateMachine?.inputs.find(i => i.id === id);
|
|
492
|
+
if (input && this.inputNameMap.has(input.name)) {
|
|
493
|
+
this.inputNameMap.get(input.name)?.forEach(nid => this.setInternalInput(nid, false));
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
this.justFiredTriggers.clear();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private findGlobalTransition(layer: any, currentStateIds: string[]): any | null {
|
|
501
|
+
for (const state of layer.states) {
|
|
502
|
+
const isActive = currentStateIds.includes(state.id);
|
|
503
|
+
if (isActive && this.justFiredTriggers.size === 0) continue;
|
|
504
|
+
|
|
505
|
+
for (const trans of state.transitions) {
|
|
506
|
+
const targetsActive = currentStateIds.includes(trans.targetStateId);
|
|
507
|
+
if (targetsActive && this.justFiredTriggers.size === 0) continue;
|
|
508
|
+
|
|
509
|
+
if (this.checkConditions(trans.conditions)) {
|
|
510
|
+
return trans;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
355
515
|
}
|
|
356
516
|
|
|
357
517
|
private checkConditions(conditions: any[]): boolean {
|
|
@@ -362,7 +522,7 @@ export class ExodeUIEngine {
|
|
|
362
522
|
const op = cond.op;
|
|
363
523
|
const targetValue = cond.value;
|
|
364
524
|
|
|
365
|
-
const inputValue = this.
|
|
525
|
+
const inputValue = this.evaluateLogicTree(inputId);
|
|
366
526
|
|
|
367
527
|
if (inputValue === undefined) return false;
|
|
368
528
|
|
|
@@ -382,10 +542,57 @@ export class ExodeUIEngine {
|
|
|
382
542
|
});
|
|
383
543
|
}
|
|
384
544
|
|
|
385
|
-
|
|
545
|
+
private evaluateLogicTree(sourceId: string, sourceHandleId?: string, visited: Set<string> = new Set()): any {
|
|
546
|
+
if (visited.has(sourceId)) return false;
|
|
547
|
+
visited.add(sourceId);
|
|
548
|
+
|
|
549
|
+
const logicNode = this.activeStateMachine?.logicNodes?.find((n: LogicNode) => n.id === sourceId);
|
|
550
|
+
if (logicNode) {
|
|
551
|
+
const getInputValue = (portId: string, defaultValue: any) => {
|
|
552
|
+
const inputPort = logicNode.inputs.find((i: any) => i.id === portId);
|
|
553
|
+
if (!inputPort) return defaultValue;
|
|
554
|
+
|
|
555
|
+
if (inputPort.sourceId) {
|
|
556
|
+
return this.evaluateLogicTree(inputPort.sourceId, inputPort.sourceHandleId, visited);
|
|
557
|
+
}
|
|
558
|
+
return inputPort.value !== undefined ? inputPort.value : defaultValue;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
switch (logicNode.op) {
|
|
562
|
+
case LogicOp.AND: return getInputValue('a', false) && getInputValue('b', false);
|
|
563
|
+
case LogicOp.OR: return getInputValue('a', false) || getInputValue('b', false);
|
|
564
|
+
case LogicOp.NOT: return !getInputValue('in', false);
|
|
565
|
+
case LogicOp.XOR: return !!getInputValue('a', false) !== !!getInputValue('b', false);
|
|
566
|
+
default: return 0;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let value = this.inputs.get(sourceId);
|
|
571
|
+
if (this.justFiredTriggers.has(sourceId)) {
|
|
572
|
+
value = true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (sourceHandleId && value !== undefined) {
|
|
576
|
+
const input = this.activeStateMachine?.inputs.find(i => i.id === sourceId);
|
|
577
|
+
if (input && input.value.type === 'Number' && typeof value === 'number') {
|
|
578
|
+
const threshold = (input.value as any).defaultValue ?.(input.value as any).value;
|
|
579
|
+
if (sourceHandleId === 'source-greater') return value > threshold;
|
|
580
|
+
if (sourceHandleId === 'source-less') return value < threshold;
|
|
581
|
+
if (sourceHandleId === 'source-equal') return value === threshold;
|
|
582
|
+
}
|
|
583
|
+
if (sourceHandleId === 'source-true') return value === true;
|
|
584
|
+
if (sourceHandleId === 'source-false') return value === false;
|
|
585
|
+
if (sourceHandleId === 'source-fire') {
|
|
586
|
+
return this.justFiredTriggers.has(sourceId);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return value !== undefined ? value : 0;
|
|
591
|
+
}
|
|
592
|
+
|
|
386
593
|
private activeTriggers: Map<string, {
|
|
387
594
|
triggerId: string;
|
|
388
|
-
animation:
|
|
595
|
+
animation: SDKAnimation;
|
|
389
596
|
time: number;
|
|
390
597
|
phase: 'entry' | 'hold' | 'exit';
|
|
391
598
|
elapsedHold: number;
|
|
@@ -396,15 +603,22 @@ export class ExodeUIEngine {
|
|
|
396
603
|
|
|
397
604
|
// 1. Step Physics
|
|
398
605
|
if (this.physicsEngine) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
state
|
|
406
|
-
|
|
407
|
-
state
|
|
606
|
+
this.physicsEngine.step(dt);
|
|
607
|
+
|
|
608
|
+
this.artboard.objects?.forEach(obj => {
|
|
609
|
+
if (obj.physics?.enabled) {
|
|
610
|
+
const pos = this.physicsEngine!.getPosition(obj.id);
|
|
611
|
+
const rot = this.physicsEngine!.getRotation(obj.id);
|
|
612
|
+
const state = this.objectStates.get(obj.id);
|
|
613
|
+
|
|
614
|
+
if (state && pos !== null) {
|
|
615
|
+
state.x = pos.x;
|
|
616
|
+
state.y = pos.y;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (state && rot !== null) {
|
|
620
|
+
state.rotation = rot * (180 / Math.PI);
|
|
621
|
+
}
|
|
408
622
|
}
|
|
409
623
|
});
|
|
410
624
|
}
|
|
@@ -413,31 +627,35 @@ export class ExodeUIEngine {
|
|
|
413
627
|
if (this.activeStateMachine) {
|
|
414
628
|
this.evaluateTransitions();
|
|
415
629
|
|
|
416
|
-
this.layerStates
|
|
417
|
-
if (
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const duration = state.animation.duration;
|
|
421
|
-
const shouldLoop = state.currentState?.loop !== false;
|
|
630
|
+
this.layerStates?.forEach(layerState => {
|
|
631
|
+
if (layerState.animations.length > 0) {
|
|
632
|
+
layerState.time += dt;
|
|
422
633
|
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
634
|
+
if (layerState.time > layerState.duration) {
|
|
635
|
+
const canLoop = layerState.animations.some(a => a.state?.loop === true);
|
|
636
|
+
if (canLoop) {
|
|
637
|
+
layerState.time %= layerState.duration;
|
|
426
638
|
} else {
|
|
427
|
-
|
|
639
|
+
layerState.time = layerState.duration;
|
|
428
640
|
}
|
|
429
641
|
}
|
|
430
642
|
|
|
431
|
-
|
|
643
|
+
layerState.animations?.forEach(animObj => {
|
|
644
|
+
this.applyAnimation(animObj.animation, layerState.time);
|
|
645
|
+
});
|
|
432
646
|
}
|
|
433
647
|
});
|
|
434
648
|
}
|
|
435
649
|
|
|
650
|
+
// Solve Constraints
|
|
651
|
+
this.solveConstraints(dt);
|
|
652
|
+
|
|
436
653
|
// Advance Active Triggers
|
|
437
|
-
this.activeTriggers
|
|
654
|
+
this.activeTriggers?.forEach((state, objectId) => {
|
|
438
655
|
if (state.phase === 'entry') {
|
|
439
656
|
state.time += dt;
|
|
440
657
|
if (state.time >= state.animation.duration) {
|
|
658
|
+
const trigger = this.artboard?.objects.find(o => o.id === objectId)?.triggers?.find(t => t.id === state.triggerId);
|
|
441
659
|
state.phase = 'hold';
|
|
442
660
|
state.elapsedHold = 0;
|
|
443
661
|
state.time = state.animation.duration;
|
|
@@ -471,6 +689,97 @@ export class ExodeUIEngine {
|
|
|
471
689
|
}
|
|
472
690
|
}
|
|
473
691
|
});
|
|
692
|
+
|
|
693
|
+
// 4. Apply Bindings
|
|
694
|
+
this.applyBindings();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private applyBindings() {
|
|
698
|
+
if (!this.artboard) return;
|
|
699
|
+
this.artboard.objects?.forEach(obj => {
|
|
700
|
+
const bindings = (obj as any).bindings || [];
|
|
701
|
+
if (bindings.length > 0) {
|
|
702
|
+
bindings?.forEach((binding: any) => {
|
|
703
|
+
const val = this.evaluateLogicTree(binding.inputId);
|
|
704
|
+
const state = this.objectStates.get(obj.id);
|
|
705
|
+
if (state && val !== undefined) {
|
|
706
|
+
state[binding.property] = val;
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private solveConstraints(dt: number) {
|
|
714
|
+
if (!this.artboard || !this.artboard.objects) return;
|
|
715
|
+
|
|
716
|
+
this.artboard.objects?.forEach(obj => {
|
|
717
|
+
if (obj.constraints && obj.constraints.length > 0) {
|
|
718
|
+
obj.constraints?.forEach(constraint => {
|
|
719
|
+
this.applyConstraint(obj.id, constraint, dt);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private applyConstraint(objectId: string, constraint: Constraint, dt: number) {
|
|
726
|
+
const state = this.objectStates.get(objectId);
|
|
727
|
+
const targetState = this.objectStates.get(constraint.targetId);
|
|
728
|
+
|
|
729
|
+
if (!state || !targetState) return;
|
|
730
|
+
|
|
731
|
+
const strength = constraint.strength ?? 1;
|
|
732
|
+
|
|
733
|
+
switch (constraint.type) {
|
|
734
|
+
case 'Translation':
|
|
735
|
+
if (constraint.useSpring) {
|
|
736
|
+
this.applySpring(objectId, 'x', targetState.x, constraint, dt);
|
|
737
|
+
this.applySpring(objectId, 'y', targetState.y, constraint, dt);
|
|
738
|
+
} else {
|
|
739
|
+
if (constraint.copyX !== false) state.x += (targetState.x - state.x) * strength;
|
|
740
|
+
if (constraint.copyY !== false) state.y += (targetState.y - state.y) * strength;
|
|
741
|
+
}
|
|
742
|
+
break;
|
|
743
|
+
case 'Rotation':
|
|
744
|
+
if (constraint.useSpring) {
|
|
745
|
+
this.applySpring(objectId, 'rotation', targetState.rotation, constraint, dt);
|
|
746
|
+
} else {
|
|
747
|
+
state.rotation += (targetState.rotation - state.rotation) * strength;
|
|
748
|
+
}
|
|
749
|
+
break;
|
|
750
|
+
case 'Scale':
|
|
751
|
+
if (constraint.useSpring) {
|
|
752
|
+
this.applySpring(objectId, 'scale_x', targetState.scale_x, constraint, dt);
|
|
753
|
+
this.applySpring(objectId, 'scale_y', targetState.scale_y, constraint, dt);
|
|
754
|
+
} else {
|
|
755
|
+
state.scale_x += (targetState.scale_x - state.scale_x) * strength;
|
|
756
|
+
state.scale_y += (targetState.scale_y - state.scale_y) * strength;
|
|
757
|
+
}
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private applySpring(objectId: string, property: string, targetValue: number, constraint: Constraint, dt: number) {
|
|
763
|
+
const state = this.objectStates.get(objectId);
|
|
764
|
+
if (!state) return;
|
|
765
|
+
|
|
766
|
+
const current = state[property];
|
|
767
|
+
const velocityKey = `${objectId}_${property}`;
|
|
768
|
+
let v = this.objectVelocities.get(velocityKey) || { vx: 0, vy: 0 };
|
|
769
|
+
|
|
770
|
+
const k = constraint.stiffness ?? 100;
|
|
771
|
+
const d = constraint.damping ?? 10;
|
|
772
|
+
const m = constraint.mass ?? 1;
|
|
773
|
+
|
|
774
|
+
// F = -k * (x - target) - d * v
|
|
775
|
+
const x = current - targetValue;
|
|
776
|
+
const f = -k * x - d * v.vx;
|
|
777
|
+
const a = f / m;
|
|
778
|
+
|
|
779
|
+
v.vx += a * dt;
|
|
780
|
+
state[property] += v.vx * dt;
|
|
781
|
+
|
|
782
|
+
this.objectVelocities.set(velocityKey, v);
|
|
474
783
|
}
|
|
475
784
|
|
|
476
785
|
handlePointerInput(type: string, canvasX: number, canvasY: number, canvasWidth: number, canvasHeight: number) {
|
|
@@ -486,72 +795,437 @@ export class ExodeUIEngine {
|
|
|
486
795
|
const artboardX = ((canvasX - transform.tx) / transform.scaleX) - (this.artboard.width / 2);
|
|
487
796
|
const artboardY = ((canvasY - transform.ty) / transform.scaleY) - (this.artboard.height / 2);
|
|
488
797
|
|
|
798
|
+
this._prevPointerPos = this._lastPointerPos;
|
|
799
|
+
this._lastPointerPos = { x: artboardX, y: artboardY };
|
|
800
|
+
|
|
801
|
+
this.updateInput('mouseX', artboardX);
|
|
802
|
+
this.updateInput('mouseY', artboardY);
|
|
803
|
+
|
|
489
804
|
this.handlePointerEvent(type, artboardX, artboardY);
|
|
490
805
|
}
|
|
491
806
|
|
|
492
807
|
private handlePointerEvent(type: string, x: number, y: number) {
|
|
493
808
|
if (!this.artboard) return;
|
|
494
809
|
|
|
810
|
+
let hitObj: any = null;
|
|
811
|
+
console.log(`[ExodeUIEngine] PointerEvent: ${type} at ${x.toFixed(1)},${y.toFixed(1)}`);
|
|
812
|
+
|
|
813
|
+
const objectHandlesEvent = (obj: any, evType: string) => {
|
|
814
|
+
let artboardEvent = evType;
|
|
815
|
+
if (evType === 'click') artboardEvent = 'onClick';
|
|
816
|
+
else if (evType === 'PointerDown') artboardEvent = 'onPointerDown';
|
|
817
|
+
else if (evType === 'PointerUp') artboardEvent = 'onPointerUp';
|
|
818
|
+
else if (evType === 'PointerMove') artboardEvent = 'hover';
|
|
819
|
+
|
|
820
|
+
const aliases = new Set([artboardEvent]);
|
|
821
|
+
if (evType === 'PointerMove') {
|
|
822
|
+
aliases.add('onPointerEnter').add('hover').add('onPointerLeave');
|
|
823
|
+
}
|
|
824
|
+
const interacts = ['button', 'toggle', 'toggle_button', 'dropdown', 'listview', 'inputbox', 'slider'];
|
|
825
|
+
if (obj.type === 'Component' && interacts.includes((obj as any).variant)) return true;
|
|
826
|
+
if (interacts.includes(obj.type?.toLowerCase())) return true;
|
|
827
|
+
|
|
828
|
+
const interactions = obj.interactions || [];
|
|
829
|
+
if (interactions.some((int: any) => aliases.has(int.event))) return true;
|
|
830
|
+
|
|
831
|
+
const triggers = obj.triggers || [];
|
|
832
|
+
if (triggers.some((t: any) => aliases.has(t.eventType))) return true;
|
|
833
|
+
|
|
834
|
+
return false;
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
let topHit: any = null;
|
|
495
838
|
for (let i = this.artboard.objects.length - 1; i >= 0; i--) {
|
|
496
839
|
const obj = this.artboard.objects[i];
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
840
|
+
const state = this.objectStates.get(obj.id);
|
|
841
|
+
if (state && state.visible !== false && state.opacity > 0 && this.hitTest(obj, x, y)) {
|
|
842
|
+
if (!topHit) topHit = obj;
|
|
843
|
+
if (objectHandlesEvent(obj, type)) {
|
|
844
|
+
hitObj = obj;
|
|
845
|
+
break;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (!hitObj && topHit) hitObj = topHit;
|
|
851
|
+
|
|
852
|
+
if (hitObj) {
|
|
853
|
+
console.log(`[ExodeUIEngine] Hit object: ${hitObj.name || hitObj.id} (${hitObj.type})`);
|
|
854
|
+
} else {
|
|
855
|
+
console.log(`[ExodeUIEngine] No object hit at ${x.toFixed(1)},${y.toFixed(1)}`);
|
|
856
|
+
}
|
|
857
|
+
const hitId = hitObj?.id || null;
|
|
858
|
+
|
|
859
|
+
if (type === 'PointerMove') {
|
|
860
|
+
if (this.draggingSliderId) {
|
|
861
|
+
const obj = this.artboard.objects.find(o => o.id === this.draggingSliderId);
|
|
862
|
+
if (obj) this.updateSliderValueFromPointer(obj, x, y);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (this.draggingListViewId) {
|
|
867
|
+
const obj = this.artboard.objects.find(o => o.id === this.draggingListViewId);
|
|
868
|
+
if (obj) this.updateListViewScrollFromPointer(obj, x, y);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (hitId !== this.lastHoveredObjectId) {
|
|
873
|
+
if (this.lastHoveredObjectId) {
|
|
874
|
+
this.fireEventForObject(this.lastHoveredObjectId, 'onPointerLeave');
|
|
510
875
|
}
|
|
876
|
+
if (hitId) {
|
|
877
|
+
this.fireEventForObject(hitId, 'onPointerEnter');
|
|
878
|
+
}
|
|
879
|
+
this.lastHoveredObjectId = hitId;
|
|
880
|
+
}
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
511
883
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
884
|
+
if (type === 'PointerUp') {
|
|
885
|
+
this.draggingSliderId = null;
|
|
886
|
+
this.draggingListViewId = null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (this.activeDropdownId && type === 'PointerDown') {
|
|
890
|
+
const activeObj = this.artboard.objects.find(o => o.id === this.activeDropdownId);
|
|
891
|
+
if (activeObj && (activeObj.type === 'Dropdown' || (activeObj as any).variant === 'dropdown')) {
|
|
892
|
+
const state = this.objectStates.get(activeObj.id);
|
|
893
|
+
if (state) {
|
|
894
|
+
const opts = state.options || {};
|
|
895
|
+
const w = state.width || (activeObj.geometry as any)?.width || 160;
|
|
896
|
+
const h = state.height || (activeObj.geometry as any)?.height || 40;
|
|
897
|
+
const world = this.getWorldTransform(activeObj.id);
|
|
898
|
+
|
|
899
|
+
if (hitId !== this.activeDropdownId) {
|
|
900
|
+
// Check if click is inside the expanded options list
|
|
901
|
+
const optionsList = opts.optionsList || [];
|
|
902
|
+
const itemH = opts.itemHeight || 36;
|
|
903
|
+
const listTop = world.y + h / 2;
|
|
904
|
+
const listBottom = listTop + optionsList.length * itemH;
|
|
905
|
+
const listLeft = world.x - w / 2;
|
|
906
|
+
const listRight = world.x + w / 2;
|
|
526
907
|
|
|
527
|
-
if (
|
|
528
|
-
|
|
908
|
+
if (x >= listLeft && x <= listRight && y >= listTop && y <= listBottom) {
|
|
909
|
+
// Hit inside the options list — select item
|
|
910
|
+
const itemIndex = Math.floor((y - listTop) / itemH);
|
|
911
|
+
if (itemIndex >= 0 && itemIndex < optionsList.length) {
|
|
912
|
+
const selected = optionsList[itemIndex];
|
|
913
|
+
const selectedValue = typeof selected === 'string' ? selected : (selected?.label || selected?.value || itemIndex);
|
|
914
|
+
opts.activeItemIndex = itemIndex;
|
|
915
|
+
opts.selectedValue = selectedValue;
|
|
916
|
+
opts.isOpen = false;
|
|
917
|
+
this.activeDropdownId = null;
|
|
918
|
+
if (activeObj.inputId) this.updateInput(activeObj.inputId, selectedValue);
|
|
919
|
+
if (this.onComponentChange) {
|
|
920
|
+
this.onComponentChange({ objectId: activeObj.id, componentName: activeObj.name, variant: 'dropdown', property: 'selected', value: selectedValue });
|
|
921
|
+
}
|
|
922
|
+
console.log(`[ExodeUIEngine] Dropdown selected: ${selectedValue}`);
|
|
923
|
+
}
|
|
924
|
+
} else {
|
|
925
|
+
// Tap outside — close
|
|
926
|
+
opts.isOpen = false;
|
|
927
|
+
this.activeDropdownId = null;
|
|
529
928
|
}
|
|
530
929
|
return;
|
|
531
930
|
}
|
|
532
931
|
}
|
|
533
932
|
}
|
|
534
933
|
}
|
|
934
|
+
|
|
935
|
+
if (hitObj) {
|
|
936
|
+
let targetObj = hitObj;
|
|
937
|
+
let current: any = hitObj;
|
|
938
|
+
while (current && current.parentId) {
|
|
939
|
+
const parent = this.artboard.objects.find(o => o.id === current.parentId);
|
|
940
|
+
if (parent && parent.type === 'Component') {
|
|
941
|
+
targetObj = parent;
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
current = parent;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
let artboardEvent = type;
|
|
948
|
+
if (type === 'click') artboardEvent = 'onClick';
|
|
949
|
+
else if (type === 'PointerDown') artboardEvent = 'onPointerDown';
|
|
950
|
+
else if (type === 'PointerUp') artboardEvent = 'onPointerUp';
|
|
951
|
+
|
|
952
|
+
this.fireEventForObject(hitObj.id, artboardEvent);
|
|
953
|
+
if (targetObj !== hitObj) {
|
|
954
|
+
this.fireEventForObject(targetObj.id, artboardEvent);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (targetObj.type === 'Component' && (targetObj as any).variant === 'toggle') {
|
|
958
|
+
if (type === 'PointerDown') {
|
|
959
|
+
const state = this.objectStates.get(targetObj.id);
|
|
960
|
+
const opts = state?.options || targetObj.options || {};
|
|
961
|
+
opts.checked = !opts.checked;
|
|
962
|
+
if (targetObj.inputId) this.updateInput(targetObj.inputId, opts.checked);
|
|
963
|
+
if (this.onToggle) this.onToggle(targetObj.name, opts.checked);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (targetObj.type === 'Component' && (targetObj as any).variant === 'slider') {
|
|
968
|
+
if (type === 'PointerDown') {
|
|
969
|
+
this.draggingSliderId = targetObj.id;
|
|
970
|
+
this.updateSliderValueFromPointer(targetObj, x, y);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (targetObj.type === 'Component' && (targetObj as any).variant === 'dropdown') {
|
|
975
|
+
if (type === 'PointerDown') {
|
|
976
|
+
const state = this.objectStates.get(targetObj.id);
|
|
977
|
+
if (!state) return;
|
|
978
|
+
if (!state.options) state.options = {};
|
|
979
|
+
const isOpening = !state.options.isOpen;
|
|
980
|
+
state.options.isOpen = isOpening;
|
|
981
|
+
this.activeDropdownId = isOpening ? targetObj.id : null;
|
|
982
|
+
console.log(`[ExodeUIEngine] Dropdown toggled: isOpen=${isOpening}`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (targetObj.type === 'Component' && (targetObj as any).variant === 'listview') {
|
|
987
|
+
if (type === 'PointerDown') {
|
|
988
|
+
this.draggingListViewId = targetObj.id;
|
|
989
|
+
this._prevPointerPos = { x, y };
|
|
990
|
+
|
|
991
|
+
const itemIndex = this.getListViewItemIndexAtPointer(targetObj, x, y);
|
|
992
|
+
if (itemIndex !== null) {
|
|
993
|
+
const state = this.objectStates.get(targetObj.id) || {};
|
|
994
|
+
const opts = state.options || targetObj.options || {};
|
|
995
|
+
|
|
996
|
+
opts.activeItemIndex = itemIndex;
|
|
997
|
+
const selected = (opts.items || [])[itemIndex];
|
|
998
|
+
const selectedValue = typeof selected === 'string' ? selected : (selected?.label || selected?.value || itemIndex);
|
|
999
|
+
opts.selectedValue = selectedValue;
|
|
1000
|
+
|
|
1001
|
+
if (targetObj.inputId) {
|
|
1002
|
+
this.updateInput(targetObj.inputId, selectedValue);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
if (this.onComponentChange) {
|
|
1006
|
+
this.onComponentChange({
|
|
1007
|
+
objectId: targetObj.id,
|
|
1008
|
+
componentName: targetObj.name,
|
|
1009
|
+
variant: 'listview',
|
|
1010
|
+
property: 'activeItemIndex',
|
|
1011
|
+
value: itemIndex
|
|
1012
|
+
});
|
|
1013
|
+
this.onComponentChange({
|
|
1014
|
+
objectId: targetObj.id,
|
|
1015
|
+
componentName: targetObj.name,
|
|
1016
|
+
variant: 'listview',
|
|
1017
|
+
property: 'selected',
|
|
1018
|
+
value: selectedValue
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private fireEventForObject(objectId: string, eventType: string) {
|
|
1028
|
+
if (!this.artboard) return;
|
|
1029
|
+
const obj = this.artboard.objects.find(o => o.id === objectId);
|
|
1030
|
+
if (!obj) return;
|
|
1031
|
+
|
|
1032
|
+
const eventAliases = new Set([eventType]);
|
|
1033
|
+
if (eventType === 'onPointerEnter') eventAliases.add('hover');
|
|
1034
|
+
if (eventType === 'onPointerLeave') eventAliases.add('blur');
|
|
1035
|
+
|
|
1036
|
+
const interactions = (obj as any).interactions || [];
|
|
1037
|
+
const matchingInteraction = interactions.find((int: any) => eventAliases.has(int.event));
|
|
1038
|
+
|
|
1039
|
+
if (matchingInteraction) {
|
|
1040
|
+
if (matchingInteraction.action === 'setInput') {
|
|
1041
|
+
this.updateInput(matchingInteraction.targetInputId, matchingInteraction.value);
|
|
1042
|
+
} else if (matchingInteraction.action === 'fireTrigger') {
|
|
1043
|
+
this.updateInput(matchingInteraction.targetInputId, true);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const triggers = obj.triggers || [];
|
|
1048
|
+
const matchingTrigger = triggers.find(t => eventAliases.has(t.eventType));
|
|
1049
|
+
|
|
1050
|
+
if (matchingTrigger && matchingTrigger.entryAnimationId) {
|
|
1051
|
+
const anim = this.artboard.animations.find(a => a.id === matchingTrigger.entryAnimationId);
|
|
1052
|
+
if (anim) {
|
|
1053
|
+
this.activeTriggers.set(obj.id, {
|
|
1054
|
+
triggerId: matchingTrigger.id,
|
|
1055
|
+
animation: anim,
|
|
1056
|
+
time: 0,
|
|
1057
|
+
phase: 'entry',
|
|
1058
|
+
elapsedHold: 0
|
|
1059
|
+
});
|
|
1060
|
+
if (this.onTrigger) this.onTrigger(matchingTrigger.name, anim.name);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private updateSliderValueFromPointer(obj: any, x: number, y: number) {
|
|
1066
|
+
const state = this.objectStates.get(obj.id);
|
|
1067
|
+
const world = this.getWorldTransform(obj.id);
|
|
1068
|
+
const opts = state?.options || obj.options || {};
|
|
1069
|
+
const w = obj.geometry?.width || 200;
|
|
1070
|
+
|
|
1071
|
+
const thumbWidth = opts.thumbWidth ?? 16;
|
|
1072
|
+
const travelW = w - thumbWidth;
|
|
1073
|
+
|
|
1074
|
+
const relativeX = (x - world.x) / world.scaleX;
|
|
1075
|
+
const percentage = Math.max(0, Math.min(1, (relativeX + travelW / 2) / travelW));
|
|
1076
|
+
|
|
1077
|
+
const min = opts.min ?? 0;
|
|
1078
|
+
const max = opts.max ?? 100;
|
|
1079
|
+
const newValue = min + (percentage * (max - min));
|
|
1080
|
+
|
|
1081
|
+
this.updateObjectOptions(obj.id, { value: newValue });
|
|
1082
|
+
if (obj.inputId) this.updateInput(obj.inputId, newValue);
|
|
1083
|
+
if (this.onComponentChange) {
|
|
1084
|
+
this.onComponentChange({
|
|
1085
|
+
objectId: obj.id,
|
|
1086
|
+
componentName: obj.name,
|
|
1087
|
+
variant: 'slider',
|
|
1088
|
+
property: 'value',
|
|
1089
|
+
value: newValue
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
private updateListViewScrollFromPointer(obj: any, x: number, y: number) {
|
|
1095
|
+
if (!this._prevPointerPos) {
|
|
1096
|
+
this._prevPointerPos = { x, y };
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const dy = y - this._prevPointerPos.y;
|
|
1100
|
+
const state = this.objectStates.get(obj.id) || {};
|
|
1101
|
+
const opts = state.options || obj.options || {};
|
|
1102
|
+
opts.scrollOffset = (opts.scrollOffset || 0) + dy;
|
|
1103
|
+
|
|
1104
|
+
const items = opts.items || [];
|
|
1105
|
+
const itemGap = opts.itemGap ?? 10;
|
|
1106
|
+
const itemHeight = opts.itemHeight ?? 50;
|
|
1107
|
+
const h = state.height !== undefined ? state.height : (obj.geometry?.height || 300);
|
|
1108
|
+
|
|
1109
|
+
const totalSize = (items.length || 0) * itemHeight + Math.max(0, (items.length || 0) - 1) * itemGap;
|
|
1110
|
+
const maxScroll = Math.max(0, totalSize - h + (opts.padding * 2 || 0));
|
|
1111
|
+
|
|
1112
|
+
if (opts.scrollOffset > 0) opts.scrollOffset = 0;
|
|
1113
|
+
if (opts.scrollOffset < -maxScroll) opts.scrollOffset = -maxScroll;
|
|
1114
|
+
|
|
1115
|
+
this._prevPointerPos = { x, y };
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private updateListViewHoverFromPointer(obj: any, x: number, y: number) {
|
|
1119
|
+
const index = this.getListViewItemIndexAtPointer(obj, x, y);
|
|
1120
|
+
const state = this.objectStates.get(obj.id) || {};
|
|
1121
|
+
const opts = state.options || obj.options || {};
|
|
1122
|
+
if (opts.hoveredIndex !== index) {
|
|
1123
|
+
opts.hoveredIndex = index;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
private getListViewItemIndexAtPointer(obj: any, x: number, y: number): number | null {
|
|
1128
|
+
const state = this.objectStates.get(obj.id) || {};
|
|
1129
|
+
const opts = state.options || obj.options || {};
|
|
1130
|
+
const items = opts.items || [];
|
|
1131
|
+
if (items.length === 0) return null;
|
|
1132
|
+
|
|
1133
|
+
const w = state.width !== undefined ? state.width : (obj.geometry?.width || 200);
|
|
1134
|
+
const h = state.height !== undefined ? state.height : (obj.geometry?.height || 300);
|
|
1135
|
+
const itemGap = opts.itemGap ?? 10;
|
|
1136
|
+
const itemHeight = opts.itemHeight ?? 50;
|
|
1137
|
+
const scrollOffset = opts.scrollOffset ?? 0;
|
|
1138
|
+
let padVal = opts.padding ?? 0;
|
|
1139
|
+
if (typeof padVal !== 'number') padVal = padVal[0] || 0;
|
|
1140
|
+
|
|
1141
|
+
const worldTransform = this.getWorldTransform(obj.id);
|
|
1142
|
+
const dx = x - worldTransform.x;
|
|
1143
|
+
const dy = y - worldTransform.y;
|
|
1144
|
+
const rad = -(worldTransform.rotation) * (Math.PI / 180);
|
|
1145
|
+
const cos = Math.cos(rad);
|
|
1146
|
+
const sin = Math.sin(rad);
|
|
1147
|
+
const localX = dx * cos - dy * sin;
|
|
1148
|
+
const localY = dx * sin + dy * cos;
|
|
1149
|
+
|
|
1150
|
+
const startX = -w / 2;
|
|
1151
|
+
const startY = -h / 2;
|
|
1152
|
+
|
|
1153
|
+
let currentPos = startY + scrollOffset + padVal;
|
|
1154
|
+
|
|
1155
|
+
for (let i = 0; i < items.length; i++) {
|
|
1156
|
+
const iW = w;
|
|
1157
|
+
const iH = itemHeight;
|
|
1158
|
+
const iX = startX;
|
|
1159
|
+
const iY = currentPos;
|
|
1160
|
+
|
|
1161
|
+
if (localX >= iX && localX <= iX + iW && localY >= iY && localY <= iY + iH) {
|
|
1162
|
+
return i;
|
|
1163
|
+
}
|
|
1164
|
+
currentPos += itemHeight + itemGap;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
return null;
|
|
535
1168
|
}
|
|
536
1169
|
|
|
537
1170
|
private hitTest(obj: ShapeObject, x: number, y: number): boolean {
|
|
538
1171
|
const state = this.objectStates.get(obj.id);
|
|
539
|
-
if (!state) return false;
|
|
1172
|
+
if (!state || state.visible === false) return false;
|
|
1173
|
+
|
|
1174
|
+
const world = this.getWorldTransform(obj.id);
|
|
1175
|
+
const w = state.width || (obj.geometry as any).width || 100;
|
|
1176
|
+
const h = state.height || (obj.geometry as any).height || 100;
|
|
1177
|
+
|
|
1178
|
+
const dx = x - world.x;
|
|
1179
|
+
const dy = y - world.y;
|
|
1180
|
+
|
|
1181
|
+
// Basic AABB check in world space
|
|
1182
|
+
// Note: Full matrix-based hit test would be better for rotated objects
|
|
1183
|
+
return Math.abs(dx) <= (w * Math.abs(world.scaleX)) / 2 &&
|
|
1184
|
+
Math.abs(dy) <= (h * Math.abs(world.scaleY)) / 2;
|
|
1185
|
+
}
|
|
540
1186
|
|
|
541
|
-
|
|
542
|
-
const
|
|
1187
|
+
private getWorldTransform(objId: string): { x: number, y: number, scaleX: number, scaleY: number, rotation: number } {
|
|
1188
|
+
const state = this.objectStates.get(objId);
|
|
1189
|
+
if (!state) return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 };
|
|
543
1190
|
|
|
544
|
-
const
|
|
545
|
-
|
|
1191
|
+
const obj = this.artboard?.objects.find(o => o.id === objId);
|
|
1192
|
+
if (!obj || !obj.parentId) {
|
|
1193
|
+
return {
|
|
1194
|
+
x: state.x,
|
|
1195
|
+
y: state.y,
|
|
1196
|
+
scaleX: state.scale_x ?? 1,
|
|
1197
|
+
scaleY: state.scale_y ?? 1,
|
|
1198
|
+
rotation: state.rotation ?? 0
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const parentWorld = this.getWorldTransform(obj.parentId);
|
|
546
1203
|
|
|
547
|
-
|
|
1204
|
+
const rad = parentWorld.rotation * (Math.PI / 180);
|
|
1205
|
+
const cos = Math.cos(rad);
|
|
1206
|
+
const sin = Math.sin(rad);
|
|
1207
|
+
|
|
1208
|
+
const sx = state.x * parentWorld.scaleX;
|
|
1209
|
+
const sy = state.y * parentWorld.scaleY;
|
|
1210
|
+
|
|
1211
|
+
const worldX = parentWorld.x + (sx * cos - sy * sin);
|
|
1212
|
+
const worldY = parentWorld.y + (sx * sin + sy * cos);
|
|
1213
|
+
|
|
1214
|
+
return {
|
|
1215
|
+
x: worldX,
|
|
1216
|
+
y: worldY,
|
|
1217
|
+
scaleX: (state.scale_x ?? 1) * parentWorld.scaleX,
|
|
1218
|
+
scaleY: (state.scale_y ?? 1) * parentWorld.scaleY,
|
|
1219
|
+
rotation: parentWorld.rotation + (state.rotation ?? 0)
|
|
1220
|
+
};
|
|
548
1221
|
}
|
|
549
1222
|
|
|
550
|
-
private applyAnimation(anim:
|
|
551
|
-
anim.tracks
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1223
|
+
private applyAnimation(anim: SDKAnimation, time: number) {
|
|
1224
|
+
anim.tracks?.forEach((track: any) => {
|
|
1225
|
+
// Skip physics controlled objects if they are dynamic
|
|
1226
|
+
const isPhysicsControlled = this.physicsEngine &&
|
|
1227
|
+
this.physicsEngine.isDynamic(track.object_id);
|
|
1228
|
+
|
|
555
1229
|
if (isPhysicsControlled && (['x', 'y', 'rotation'].includes(track.property) || track.property.startsWith('transform.'))) {
|
|
556
1230
|
return;
|
|
557
1231
|
}
|
|
@@ -574,14 +1248,13 @@ export class ExodeUIEngine {
|
|
|
574
1248
|
}
|
|
575
1249
|
|
|
576
1250
|
// Sync State -> Physics Body (for Kinematic/Static animations)
|
|
577
|
-
|
|
578
|
-
if (body && body.isStatic) {
|
|
1251
|
+
if (this.physicsEngine && !this.physicsEngine.isDynamic(track.object_id)) {
|
|
579
1252
|
if (track.property === 'x' || track.property === 'transform.x') {
|
|
580
|
-
|
|
1253
|
+
this.physicsEngine.setPosition(track.object_id, value, objState.y);
|
|
581
1254
|
} else if (track.property === 'y' || track.property === 'transform.y') {
|
|
582
|
-
|
|
1255
|
+
this.physicsEngine.setPosition(track.object_id, objState.x, value);
|
|
583
1256
|
} else if (track.property === 'rotation' || track.property === 'transform.rotation') {
|
|
584
|
-
|
|
1257
|
+
this.physicsEngine.setRotation(track.object_id, value * (Math.PI / 180));
|
|
585
1258
|
}
|
|
586
1259
|
}
|
|
587
1260
|
});
|
|
@@ -605,6 +1278,7 @@ export class ExodeUIEngine {
|
|
|
605
1278
|
}
|
|
606
1279
|
|
|
607
1280
|
const duration = end.time - bg.time;
|
|
1281
|
+
if (duration <= 0) return end.value;
|
|
608
1282
|
const t = (time - bg.time) / duration;
|
|
609
1283
|
|
|
610
1284
|
if (typeof bg.value === 'string' && bg.value.startsWith('#')) {
|
|
@@ -620,6 +1294,13 @@ export class ExodeUIEngine {
|
|
|
620
1294
|
private interpolateColor(c1: string, c2: string, t: number): string {
|
|
621
1295
|
const parse = (c: string) => {
|
|
622
1296
|
const hex = c.replace('#', '');
|
|
1297
|
+
if (hex.length === 3) {
|
|
1298
|
+
return {
|
|
1299
|
+
r: parseInt(hex[0] + hex[0], 16),
|
|
1300
|
+
g: parseInt(hex[1] + hex[1], 16),
|
|
1301
|
+
b: parseInt(hex[2] + hex[2], 16)
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
623
1304
|
return {
|
|
624
1305
|
r: parseInt(hex.substring(0, 2), 16),
|
|
625
1306
|
g: parseInt(hex.substring(2, 4), 16),
|
|
@@ -647,11 +1328,28 @@ export class ExodeUIEngine {
|
|
|
647
1328
|
return;
|
|
648
1329
|
}
|
|
649
1330
|
|
|
650
|
-
|
|
1331
|
+
try {
|
|
1332
|
+
const bg = this.artboard.backgroundColor || '#000000';
|
|
1333
|
+
if (bg === 'transparent' || bg === 'rgba(0,0,0,0)') {
|
|
1334
|
+
canvas.clear(Skia.Color('rgba(0,0,0,0)'));
|
|
1335
|
+
} else {
|
|
1336
|
+
canvas.clear(Skia.Color(bg));
|
|
1337
|
+
}
|
|
1338
|
+
} catch (e) {
|
|
1339
|
+
canvas.clear(Skia.Color('#000000'));
|
|
1340
|
+
}
|
|
1341
|
+
this._renderCount++;
|
|
1342
|
+
if (this._renderCount % 60 === 0) {
|
|
1343
|
+
console.log(`[ExodeUIEngine] Rendering frame ${this._renderCount}, objects: ${this.artboard.objects?.length}`);
|
|
1344
|
+
}
|
|
651
1345
|
|
|
652
1346
|
const abWidth = this.artboard.width;
|
|
653
1347
|
const abHeight = this.artboard.height;
|
|
654
1348
|
|
|
1349
|
+
if (this._renderCount % 60 === 1) {
|
|
1350
|
+
console.log(`[ExodeUIEngine] Artboard size: ${abWidth}x${abHeight}, Canvas size: ${width}x${height}`);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
655
1353
|
// Calculate Layout Transform
|
|
656
1354
|
const transform = this.calculateTransform(
|
|
657
1355
|
width, height,
|
|
@@ -664,16 +1362,22 @@ export class ExodeUIEngine {
|
|
|
664
1362
|
canvas.translate(transform.tx, transform.ty);
|
|
665
1363
|
canvas.scale(transform.scaleX, transform.scaleY);
|
|
666
1364
|
|
|
1365
|
+
if (this._renderCount % 60 === 1) {
|
|
1366
|
+
console.log(`[ExodeUIEngine] Viewport Transform: tx=${transform.tx.toFixed(2)}, ty=${transform.ty.toFixed(2)}, scale=${transform.scaleX.toFixed(2)}`);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
667
1369
|
// Center the Artboard Origin
|
|
668
1370
|
canvas.translate(abWidth / 2, abHeight / 2);
|
|
669
1371
|
|
|
670
1372
|
// Clip to artboard bounds
|
|
671
1373
|
const clipPath = Skia.Path.Make();
|
|
672
1374
|
clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
|
|
673
|
-
canvas.clipPath(clipPath, 1, true);
|
|
1375
|
+
canvas.clipPath(clipPath, 1, true); // 1 = Intersect
|
|
674
1376
|
|
|
675
|
-
this.artboard.objects
|
|
676
|
-
|
|
1377
|
+
this.artboard.objects?.forEach((obj: ShapeObject) => {
|
|
1378
|
+
if (!obj.parentId) {
|
|
1379
|
+
this.renderObject(canvas, obj);
|
|
1380
|
+
}
|
|
677
1381
|
});
|
|
678
1382
|
|
|
679
1383
|
canvas.restore();
|
|
@@ -758,106 +1462,555 @@ export class ExodeUIEngine {
|
|
|
758
1462
|
return { scaleX, scaleY, tx, ty };
|
|
759
1463
|
}
|
|
760
1464
|
|
|
761
|
-
private renderObject(canvas:
|
|
1465
|
+
private renderObject(canvas: any, obj: ShapeObject) {
|
|
762
1466
|
const state = this.objectStates.get(obj.id);
|
|
763
|
-
if (!state) return;
|
|
1467
|
+
if (!state || state.visible === false) return;
|
|
764
1468
|
|
|
765
1469
|
const geometry = state.geometry || obj.geometry;
|
|
766
|
-
|
|
767
|
-
const
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
const
|
|
1470
|
+
const w = state.width || (geometry as any).width || 0;
|
|
1471
|
+
const h = state.height || (geometry as any).height || 0;
|
|
1472
|
+
const cx = state.x || 0;
|
|
1473
|
+
const cy = state.y || 0;
|
|
1474
|
+
const rotation = state.rotation || 0;
|
|
1475
|
+
const scaleX = state.scale_x === undefined ? 1 : state.scale_x;
|
|
1476
|
+
const scaleY = state.scale_y === undefined ? 1 : state.scale_y;
|
|
772
1477
|
|
|
773
1478
|
canvas.save();
|
|
774
1479
|
canvas.translate(cx, cy);
|
|
775
|
-
canvas.rotate(
|
|
776
|
-
canvas.scale(
|
|
1480
|
+
canvas.rotate(rotation * Math.PI / 180, 0, 0);
|
|
1481
|
+
canvas.scale(scaleX, scaleY);
|
|
777
1482
|
|
|
778
|
-
|
|
1483
|
+
if (this._renderCount % 60 === 1) {
|
|
1484
|
+
console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}`);
|
|
1485
|
+
}
|
|
779
1486
|
|
|
780
1487
|
if (geometry.type === 'Text') {
|
|
781
|
-
|
|
1488
|
+
this.renderText(canvas, obj, w, h);
|
|
782
1489
|
} else if (geometry.type === 'Image') {
|
|
783
|
-
|
|
1490
|
+
this.renderImage(canvas, obj, w, h);
|
|
1491
|
+
} else if (obj.type === 'Component' && (obj as any).variant === 'button') {
|
|
1492
|
+
this.renderButton(canvas, obj, w, h);
|
|
1493
|
+
} else if (obj.type === 'Component' && (obj as any).variant === 'toggle') {
|
|
1494
|
+
this.renderToggle(canvas, obj, w, h);
|
|
1495
|
+
} else if (obj.type === 'Component' && (obj as any).variant === 'slider') {
|
|
1496
|
+
this.renderSlider(canvas, obj, w, h);
|
|
1497
|
+
} else if (obj.type === 'Component' && (obj as any).variant === 'dropdown') {
|
|
1498
|
+
this.renderDropdown(canvas, obj, w, h);
|
|
1499
|
+
} else if (geometry.type === 'LineGraph') {
|
|
1500
|
+
this.renderLineGraph(canvas, geometry, w, h);
|
|
1501
|
+
} else if (obj.type === 'Component' && (obj as any).variant === 'listview') {
|
|
1502
|
+
this.renderListView(canvas, obj, w, h);
|
|
1503
|
+
} else if (obj.type === 'Component' && (obj as any).variant === 'inputbox') {
|
|
1504
|
+
this.renderInputBox(canvas, obj, w, h);
|
|
1505
|
+
} else if (geometry.type === 'SVG') {
|
|
1506
|
+
this.renderSVG(canvas, obj, w, h);
|
|
784
1507
|
} else {
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1508
|
+
// Default to shape rendering
|
|
1509
|
+
this.renderShape(canvas, obj, w, h);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Always check for text property on non-component shapes
|
|
1513
|
+
if (obj.text && (obj.type !== 'Component' || geometry.type === 'Text')) {
|
|
1514
|
+
this.renderText(canvas, obj, w, h);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (obj.children && obj.children.length > 0) {
|
|
1518
|
+
obj.children.forEach(childId => {
|
|
1519
|
+
const childObj = this.artboard?.objects.find(o => o.id === childId);
|
|
1520
|
+
if (childObj) this.renderObject(canvas, childObj);
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
canvas.restore();
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
private getFont(size: number, family: string = 'System'): SkFont {
|
|
1528
|
+
if (size <= 0) size = 14;
|
|
1529
|
+
|
|
1530
|
+
if (this.fonts.has(size)) return this.fonts.get(size)!;
|
|
1531
|
+
|
|
1532
|
+
try {
|
|
1533
|
+
const fontMgr = Skia.FontMgr.System();
|
|
1534
|
+
let typeface = fontMgr.matchFamilyStyle(family, { weight: 400 });
|
|
1535
|
+
|
|
1536
|
+
if (!typeface) {
|
|
1537
|
+
typeface = fontMgr.matchFamilyStyle("System", { weight: 400 });
|
|
1538
|
+
}
|
|
1539
|
+
if (!typeface) {
|
|
1540
|
+
typeface = fontMgr.matchFamilyStyle("sans-serif", { weight: 400 });
|
|
1541
|
+
}
|
|
1542
|
+
if (!typeface) {
|
|
1543
|
+
typeface = fontMgr.matchFamilyStyle("Arial", { weight: 400 });
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
if (typeface) {
|
|
1547
|
+
const font = Skia.Font(typeface, size);
|
|
1548
|
+
this.fonts.set(size, font);
|
|
1549
|
+
return font;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Fallback to matchFont utility with generic sizing
|
|
1553
|
+
const font = matchFont({ fontSize: size, fontFamily: 'System' });
|
|
1554
|
+
if (font) {
|
|
1555
|
+
this.fonts.set(size, font);
|
|
1556
|
+
return font;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Absolute last resort
|
|
1560
|
+
const defaultFont = Skia.Font(null as any, size);
|
|
1561
|
+
this.fonts.set(size, defaultFont);
|
|
1562
|
+
return defaultFont;
|
|
1563
|
+
} catch (e) {
|
|
1564
|
+
console.error(`[ExodeUIEngine] getFont error: ${e}. Attempting last resort.`);
|
|
1565
|
+
try {
|
|
1566
|
+
const font = matchFont({ fontSize: size });
|
|
1567
|
+
this.fonts.set(size, font);
|
|
1568
|
+
return font;
|
|
1569
|
+
} catch (e2) {
|
|
1570
|
+
console.error(`[ExodeUIEngine] Total font failure: ${e2}`);
|
|
1571
|
+
return Skia.Font(null as any, size);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
816
1575
|
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1576
|
+
private renderText(canvas: any, obj: any, w: number, h: number) {
|
|
1577
|
+
const state = this.objectStates.get(obj.id);
|
|
1578
|
+
const geom = state?.geometry || obj.geometry;
|
|
1579
|
+
const text = obj.text || geom.text || '';
|
|
1580
|
+
if (!text) return;
|
|
1581
|
+
|
|
1582
|
+
const paint = Skia.Paint();
|
|
1583
|
+
const colorValue = obj.style?.fill?.color || '#ffffff';
|
|
1584
|
+
try {
|
|
1585
|
+
const skColor = Skia.Color(colorValue);
|
|
1586
|
+
paint.setColor(skColor);
|
|
1587
|
+
} catch (e) {
|
|
1588
|
+
paint.setColor(Skia.Color('#ffffff'));
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const opacity1 = state?.opacity !== undefined ? state.opacity : 1;
|
|
1592
|
+
const opacity2 = obj.style?.fill?.opacity !== undefined ? obj.style.fill.opacity : 1;
|
|
1593
|
+
paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
|
|
822
1594
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1595
|
+
const fontSize = geom.fontSize || 14;
|
|
1596
|
+
const font = this.getFont(fontSize, geom.fontFamily || obj.fontFamily || 'System');
|
|
1597
|
+
|
|
1598
|
+
const align = geom.textAlign || obj.textAlign || 'center';
|
|
1599
|
+
let x = -w/2;
|
|
1600
|
+
|
|
1601
|
+
// Calculate text width for alignment
|
|
1602
|
+
const textWidth = font.getTextWidth(text);
|
|
1603
|
+
if (align === 'center') {
|
|
1604
|
+
x = -textWidth / 2;
|
|
1605
|
+
} else if (align === 'right') {
|
|
1606
|
+
x = w/2 - textWidth;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
if (this._renderCount % 60 === 1) {
|
|
1610
|
+
console.log(`[ExodeUIEngine] Drawing text: "${text}" align=${align} width=${textWidth} x=${x} color=${colorValue} alpha=${paint.getAlphaf()} font_size=${font.getSize()}`);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// Check baseline (web typically aligns center of text roughly to center if no baseline is provided, but standard is y=fontSize/2 or similar)
|
|
1614
|
+
const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 3 : fontSize / 2;
|
|
1615
|
+
|
|
1616
|
+
canvas.drawText(text, x, y, paint, font);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
private renderImage(canvas: any, obj: any, w: number, h: number) {
|
|
1620
|
+
// Image support requires Skia.Image.MakeFromExternalSource or similar
|
|
1621
|
+
// Placeholder for now
|
|
1622
|
+
const paint = Skia.Paint();
|
|
1623
|
+
paint.setColor(Skia.Color('#374151'));
|
|
1624
|
+
canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
private renderButton(canvas: any, obj: any, w: number, h: number) {
|
|
1628
|
+
const state = this.objectStates.get(obj.id);
|
|
1629
|
+
const opts = state?.options || obj.options || {};
|
|
1630
|
+
const paint = Skia.Paint();
|
|
1631
|
+
|
|
1632
|
+
const isPressed = this.draggingSliderId === obj.id; // Reuse logic for now
|
|
1633
|
+
paint.setColor(Skia.Color(isPressed ? (opts.activeBackgroundColor || '#2563eb') : (opts.backgroundColor || '#3b82f6')));
|
|
1634
|
+
|
|
1635
|
+
const r = opts.cornerRadius ?? 8;
|
|
1636
|
+
canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
|
|
1637
|
+
|
|
1638
|
+
const label = opts.label || { text: opts.text || 'Button', fontSize: 14, color: '#ffffff' };
|
|
1639
|
+
if (label && label.text) {
|
|
1640
|
+
const textPaint = Skia.Paint();
|
|
1641
|
+
textPaint.setColor(Skia.Color(label.color || '#ffffff'));
|
|
1642
|
+
const fontSize = label.fontSize || 14;
|
|
1643
|
+
const font = this.getFont(fontSize);
|
|
1644
|
+
|
|
1645
|
+
// DEBUG: Cyan marker for button text
|
|
1646
|
+
const linePaint = Skia.Paint();
|
|
1647
|
+
linePaint.setColor(Skia.Color('cyan'));
|
|
1648
|
+
linePaint.setStrokeWidth(1);
|
|
1649
|
+
canvas.drawLine(-w/4, fontSize/2, w/4, fontSize/2, linePaint);
|
|
1650
|
+
|
|
1651
|
+
if (this._renderCount % 60 === 1) {
|
|
1652
|
+
console.log(`[ExodeUIEngine] Button text: "${label.text}" at ${-w/4},${fontSize/2} font_size=${font.getSize()}`);
|
|
831
1653
|
}
|
|
832
|
-
|
|
833
|
-
|
|
1654
|
+
|
|
1655
|
+
canvas.drawText(label.text, -w/4, fontSize / 2, textPaint, font);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
private renderToggle(canvas: any, obj: any, w: number, h: number) {
|
|
1660
|
+
const state = this.objectStates.get(obj.id);
|
|
1661
|
+
const opts = state?.options || obj.options || {};
|
|
1662
|
+
const checked = opts.checked || false;
|
|
1663
|
+
|
|
1664
|
+
const paint = Skia.Paint();
|
|
1665
|
+
paint.setColor(Skia.Color(checked ? (opts.activeColor || '#3b82f6') : (opts.inactiveColor || '#374151')));
|
|
1666
|
+
|
|
1667
|
+
const r = h / 2;
|
|
1668
|
+
canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
|
|
1669
|
+
|
|
1670
|
+
const thumbPaint = Skia.Paint();
|
|
1671
|
+
thumbPaint.setColor(Skia.Color('#ffffff'));
|
|
1672
|
+
const thumbRadius = (h - 8) / 2;
|
|
1673
|
+
const thumbX = checked ? (w / 2 - thumbRadius - 4) : (-w / 2 + thumbRadius + 4);
|
|
1674
|
+
canvas.drawCircle(thumbX, 0, thumbRadius, thumbPaint);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
private renderSlider(canvas: any, obj: any, w: number, h: number) {
|
|
1678
|
+
const state = this.objectStates.get(obj.id);
|
|
1679
|
+
const opts = state?.options || obj.options || {};
|
|
1680
|
+
const value = opts.value ?? 50;
|
|
1681
|
+
const min = opts.min ?? 0;
|
|
1682
|
+
const max = opts.max ?? 100;
|
|
1683
|
+
const percentage = (value - min) / (max - min);
|
|
1684
|
+
|
|
1685
|
+
const trackPaint = Skia.Paint();
|
|
1686
|
+
trackPaint.setColor(Skia.Color(opts.inactiveColor || '#374151'));
|
|
1687
|
+
const trackHeight = opts.trackHeight || 4;
|
|
1688
|
+
canvas.drawRect({ x: -w/2, y: -trackHeight/2, width: w, height: trackHeight }, trackPaint);
|
|
1689
|
+
|
|
1690
|
+
const activePaint = Skia.Paint();
|
|
1691
|
+
activePaint.setColor(Skia.Color(opts.activeColor || '#3b82f6'));
|
|
1692
|
+
const thumbWidth = opts.thumbWidth ?? 16;
|
|
1693
|
+
const travelW = w - thumbWidth;
|
|
1694
|
+
const thumbX = -w / 2 + (thumbWidth / 2) + (percentage * travelW);
|
|
1695
|
+
canvas.drawRect({ x: -w/2, y: -trackHeight/2, width: thumbX + w/2, height: trackHeight }, activePaint);
|
|
1696
|
+
|
|
1697
|
+
const thumbPaint = Skia.Paint();
|
|
1698
|
+
thumbPaint.setColor(Skia.Color(opts.thumbColor || '#ffffff'));
|
|
1699
|
+
canvas.drawCircle(thumbX, 0, thumbWidth / 2, thumbPaint);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
private renderDropdown(canvas: any, obj: any, w: number, h: number) {
|
|
1703
|
+
const state = this.objectStates.get(obj.id);
|
|
1704
|
+
const opts = state?.options || obj.options || {};
|
|
1705
|
+
const isOpen = opts.isOpen ?? false;
|
|
1706
|
+
|
|
1707
|
+
const paint = Skia.Paint();
|
|
1708
|
+
paint.setColor(Skia.Color(opts.backgroundColor || '#ffffff'));
|
|
1709
|
+
const r = opts.cornerRadius ?? 6;
|
|
1710
|
+
canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
|
|
1711
|
+
|
|
1712
|
+
const displayText = opts.selectedValue || opts.placeholder || 'Select...';
|
|
1713
|
+
const textPaint = Skia.Paint();
|
|
1714
|
+
textPaint.setColor(Skia.Color(opts.color || '#333333'));
|
|
1715
|
+
const fontSize = opts.fontSize || 14;
|
|
1716
|
+
const font = this.getFont(fontSize, 'Helvetica Neue');
|
|
1717
|
+
|
|
1718
|
+
canvas.save();
|
|
1719
|
+
canvas.translate(-w/2 + 10, fontSize / 2);
|
|
1720
|
+
canvas.drawText(String(displayText), 0, 0, textPaint, font);
|
|
1721
|
+
canvas.restore();
|
|
1722
|
+
|
|
1723
|
+
// Chevron
|
|
1724
|
+
const chevronPaint = Skia.Paint();
|
|
1725
|
+
chevronPaint.setColor(Skia.Color(opts.chevronColor || '#9ca3af'));
|
|
1726
|
+
chevronPaint.setStyle(PaintStyle.Stroke);
|
|
1727
|
+
chevronPaint.setStrokeWidth(2);
|
|
1728
|
+
const cx = w/2 - 15;
|
|
1729
|
+
const cy = isOpen ? 2 : 0;
|
|
1730
|
+
if (isOpen) {
|
|
1731
|
+
canvas.drawLine(cx - 4, cy + 2, cx, cy - 2, chevronPaint);
|
|
1732
|
+
canvas.drawLine(cx, cy - 2, cx + 4, cy + 2, chevronPaint);
|
|
1733
|
+
} else {
|
|
1734
|
+
canvas.drawLine(cx - 4, cy - 2, cx, cy + 2, chevronPaint);
|
|
1735
|
+
canvas.drawLine(cx, cy + 2, cx + 4, cy - 2, chevronPaint);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (isOpen && opts.optionsList && opts.optionsList.length > 0) {
|
|
1739
|
+
const dropList = opts.optionsList;
|
|
1740
|
+
const dropdownItemH = opts.itemHeight || 36;
|
|
1741
|
+
const dropdownY = h/2;
|
|
1742
|
+
const dropX = -w/2;
|
|
1743
|
+
const totalH = dropList.length * dropdownItemH;
|
|
1744
|
+
|
|
1745
|
+
const dropBgPaint = Skia.Paint();
|
|
1746
|
+
dropBgPaint.setColor(Skia.Color(opts.listBackgroundColor || '#ffffff'));
|
|
1747
|
+
canvas.drawRRect(Skia.RRectXY({ x: dropX, y: dropdownY, width: w, height: totalH }, 6, 6), dropBgPaint);
|
|
1748
|
+
|
|
1749
|
+
const borderP = Skia.Paint();
|
|
1750
|
+
borderP.setColor(Skia.Color(opts.listBorderColor || '#e5e7eb'));
|
|
1751
|
+
borderP.setStyle(PaintStyle.Stroke);
|
|
1752
|
+
borderP.setStrokeWidth(1);
|
|
1753
|
+
canvas.drawRRect(Skia.RRectXY({ x: dropX, y: dropdownY, width: w, height: totalH }, 6, 6), borderP);
|
|
1754
|
+
|
|
1755
|
+
dropList.forEach((item: any, i: number) => {
|
|
1756
|
+
const itemY = dropdownY + i * dropdownItemH;
|
|
1757
|
+
const isActive = opts.activeItemIndex === i;
|
|
1758
|
+
if (isActive) {
|
|
1759
|
+
const activePaint = Skia.Paint();
|
|
1760
|
+
activePaint.setColor(Skia.Color(opts.listHoverColor || '#eff6ff'));
|
|
1761
|
+
canvas.drawRect({ x: dropX, y: itemY, width: w, height: dropdownItemH }, activePaint);
|
|
1762
|
+
}
|
|
1763
|
+
const itemText = typeof item === 'string' ? item : (item?.label || `Option ${i + 1}`);
|
|
1764
|
+
const itemTextP = Skia.Paint();
|
|
1765
|
+
itemTextP.setColor(Skia.Color(opts.listTextColor || (isActive ? '#2563eb' : '#374151')));
|
|
1766
|
+
canvas.save();
|
|
1767
|
+
canvas.translate(dropX + 12, itemY + dropdownItemH / 2);
|
|
1768
|
+
canvas.drawText(String(itemText), 0, fontSize / 3, itemTextP, font);
|
|
1769
|
+
canvas.restore();
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
private renderListView(canvas: any, obj: any, w: number, h: number) {
|
|
1775
|
+
const state = this.objectStates.get(obj.id);
|
|
1776
|
+
const opts = state?.options || obj.options || {};
|
|
1777
|
+
const items = opts.items || [];
|
|
1778
|
+
const scrollOffset = opts.scrollOffset || 0;
|
|
1779
|
+
|
|
1780
|
+
const glassEffect = opts.glassEffect !== false;
|
|
1781
|
+
const cornerR = opts.cornerRadius ?? 12;
|
|
1782
|
+
let listBgColor = opts.listBackgroundColor || opts.backgroundColor || (glassEffect ? 'rgba(31,41,55,0.4)' : '#1f2937');
|
|
1783
|
+
if (listBgColor === 'transparent') listBgColor = 'rgba(0,0,0,0)';
|
|
1784
|
+
|
|
1785
|
+
const getSafeColor = (col: any, fallback: string = '#000000') => {
|
|
1786
|
+
if (!col) return Skia.Color('rgba(0,0,0,0)');
|
|
1787
|
+
if (col === 'transparent') return Skia.Color('rgba(0,0,0,0)');
|
|
1788
|
+
|
|
1789
|
+
let cleanStr = col;
|
|
1790
|
+
if (typeof col === 'object') {
|
|
1791
|
+
cleanStr = col?.color || col?.stops?.[0]?.color || fallback;
|
|
834
1792
|
}
|
|
1793
|
+
if (typeof cleanStr !== 'string') return Skia.Color(fallback);
|
|
835
1794
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1795
|
+
const clean = cleanStr.replace(/\s+/g, '');
|
|
1796
|
+
try {
|
|
1797
|
+
return Skia.Color(clean) || Skia.Color(fallback);
|
|
1798
|
+
} catch {
|
|
1799
|
+
return Skia.Color(fallback);
|
|
1800
|
+
}
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
const bgPaint = Skia.Paint();
|
|
1804
|
+
bgPaint.setColor(getSafeColor(listBgColor, '#1f2937'));
|
|
1805
|
+
bgPaint.setStyle(PaintStyle.Fill);
|
|
1806
|
+
|
|
1807
|
+
const rRect = Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, cornerR, cornerR);
|
|
1808
|
+
canvas.drawRRect(rRect, bgPaint);
|
|
1809
|
+
|
|
1810
|
+
if (glassEffect) {
|
|
1811
|
+
const borderPaint = Skia.Paint();
|
|
1812
|
+
borderPaint.setColor(getSafeColor('rgba(255,255,255,0.25)'));
|
|
1813
|
+
borderPaint.setStyle(PaintStyle.Stroke);
|
|
1814
|
+
borderPaint.setStrokeWidth(1);
|
|
1815
|
+
canvas.drawRRect(rRect, borderPaint);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
canvas.save();
|
|
1819
|
+
// const clipOp = ClipOp ? ClipOp.Intersect : 1;
|
|
1820
|
+
// canvas.clipRRect(rRect, clipOp, true);
|
|
1821
|
+
|
|
1822
|
+
const itemHeight = opts.itemHeight || 50;
|
|
1823
|
+
const itemGap = opts.itemGap ?? 10;
|
|
1824
|
+
const padding = opts.padding ?? 0;
|
|
1825
|
+
const pad = typeof padding === 'number' ? padding : (padding as any)[0] || 0;
|
|
1826
|
+
|
|
1827
|
+
let currentPos = -h/2 + scrollOffset + pad;
|
|
1828
|
+
|
|
1829
|
+
const font = this.getFont(14, 'Helvetica Neue');
|
|
1830
|
+
const accentColor = opts.accentColor || '#3b82f6';
|
|
1831
|
+
const itemTextColor = opts.itemTextColor || '#333333';
|
|
1832
|
+
const itemBgColor = opts.itemBackgroundColor || 'transparent';
|
|
1833
|
+
|
|
1834
|
+
if (this._renderCount <= 2) {
|
|
1835
|
+
// debug logging disabled
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
items.forEach((item: any, i: number) => {
|
|
1839
|
+
const itemY = currentPos;
|
|
1840
|
+
const itemH = itemHeight;
|
|
1841
|
+
const itemW = w;
|
|
1842
|
+
|
|
1843
|
+
if (itemY + itemH > -h/2 && itemY < h/2) {
|
|
1844
|
+
const isHovered = opts.hoveredIndex === i;
|
|
1845
|
+
const isActive = opts.activeItemIndex === i;
|
|
1846
|
+
|
|
1847
|
+
if (isActive) {
|
|
1848
|
+
const activePaint = Skia.Paint();
|
|
1849
|
+
activePaint.setColor(getSafeColor(accentColor, '#3b82f6'));
|
|
1850
|
+
activePaint.setAlphaf(0.8);
|
|
1851
|
+
canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, activePaint);
|
|
1852
|
+
} else if (isHovered) {
|
|
1853
|
+
const hoverPaint = Skia.Paint();
|
|
1854
|
+
hoverPaint.setColor(getSafeColor(glassEffect ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'));
|
|
1855
|
+
canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, hoverPaint);
|
|
1856
|
+
} else if (itemBgColor && itemBgColor !== 'transparent') {
|
|
1857
|
+
const itemBgPaint = Skia.Paint();
|
|
1858
|
+
itemBgPaint.setColor(getSafeColor(itemBgColor));
|
|
1859
|
+
canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, itemBgPaint);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
if (isActive || isHovered) {
|
|
1863
|
+
const stripePaint = Skia.Paint();
|
|
1864
|
+
stripePaint.setColor(getSafeColor(accentColor, '#3b82f6'));
|
|
1865
|
+
canvas.drawRect({ x: -w/2, y: itemY, width: 3, height: itemH }, stripePaint);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
const textPaint = Skia.Paint();
|
|
1869
|
+
if (isActive) {
|
|
1870
|
+
textPaint.setColor(getSafeColor('#ffffff'));
|
|
1871
|
+
} else if (glassEffect) {
|
|
1872
|
+
textPaint.setColor(getSafeColor('#ffffff'));
|
|
1873
|
+
textPaint.setAlphaf(0.9);
|
|
1874
|
+
} else {
|
|
1875
|
+
textPaint.setColor(getSafeColor(itemTextColor, '#ffffff'));
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const text = typeof item === 'string' ? item : (item?.label || item?.value || `Item ${i + 1}`);
|
|
1879
|
+
canvas.save();
|
|
1880
|
+
canvas.translate(-w/2 + 16, itemY + itemH / 2);
|
|
1881
|
+
canvas.drawText(String(text), 0, 14 / 3, textPaint, font);
|
|
1882
|
+
canvas.restore();
|
|
856
1883
|
}
|
|
1884
|
+
currentPos += itemHeight + itemGap;
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
canvas.restore();
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
private renderInputBox(canvas: any, obj: any, w: number, h: number) {
|
|
1891
|
+
const state = this.objectStates.get(obj.id);
|
|
1892
|
+
const opts = state?.options || obj.options || {};
|
|
1893
|
+
|
|
1894
|
+
const paint = Skia.Paint();
|
|
1895
|
+
paint.setColor(Skia.Color(opts.backgroundColor || '#1f2937'));
|
|
1896
|
+
const r = opts.cornerRadius ?? 8;
|
|
1897
|
+
canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
|
|
1898
|
+
|
|
1899
|
+
const textPaint = Skia.Paint();
|
|
1900
|
+
textPaint.setColor(Skia.Color(opts.color || '#ffffff'));
|
|
1901
|
+
const fontSize = opts.fontSize || 14;
|
|
1902
|
+
const font = this.getFont(fontSize);
|
|
1903
|
+
const text = opts.text || '';
|
|
1904
|
+
const display = text || opts.placeholder || 'Enter text...';
|
|
1905
|
+
if (!text) textPaint.setAlphaf(0.5);
|
|
1906
|
+
|
|
1907
|
+
canvas.drawText(display, -w/2 + 15, 5, textPaint, font);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
private renderSVG(canvas: any, obj: any, w: number, h: number) {
|
|
1911
|
+
const geometry = obj.geometry;
|
|
1912
|
+
const svgContent = geometry.svgContent;
|
|
1913
|
+
if (!svgContent) return;
|
|
1914
|
+
|
|
1915
|
+
// In a real implementation, we'd parse the SVG or use a pre-parsed Skia Path
|
|
1916
|
+
// For now, placeholder or basic path if it's a simple icon
|
|
1917
|
+
const paint = Skia.Paint();
|
|
1918
|
+
paint.setColor(Skia.Color('#ffffff'));
|
|
1919
|
+
paint.setAlphaf(0.8);
|
|
1920
|
+
canvas.drawCircle(0, 0, Math.min(w, h) / 4, paint);
|
|
1921
|
+
}
|
|
857
1922
|
|
|
1923
|
+
private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
|
|
1924
|
+
const state = this.objectStates.get(geom.id);
|
|
1925
|
+
const datasets = geom.datasets || [];
|
|
1926
|
+
if (datasets.length === 0) return;
|
|
1927
|
+
|
|
1928
|
+
const paint = Skia.Paint();
|
|
1929
|
+
paint.setStyle(PaintStyle.Stroke);
|
|
1930
|
+
paint.setStrokeWidth(geom.lineWidth || 2);
|
|
1931
|
+
|
|
1932
|
+
datasets.forEach((ds: any) => {
|
|
1933
|
+
const data = ds.data || [];
|
|
1934
|
+
if (data.length < 2) return;
|
|
1935
|
+
|
|
1936
|
+
paint.setColor(Skia.Color(ds.lineColor || '#3b82f6'));
|
|
1937
|
+
const path = Skia.Path.Make();
|
|
1938
|
+
|
|
1939
|
+
const stepX = w / (data.length - 1);
|
|
1940
|
+
const max = Math.max(...data, 1);
|
|
1941
|
+
|
|
1942
|
+
data.forEach((val: number, i: number) => {
|
|
1943
|
+
const x = -w/2 + i * stepX;
|
|
1944
|
+
const y = h/2 - (val / max) * h;
|
|
1945
|
+
if (i === 0) path.moveTo(x, y);
|
|
1946
|
+
else path.lineTo(x, y);
|
|
1947
|
+
});
|
|
858
1948
|
canvas.drawPath(path, paint);
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
private renderShape(canvas: SkCanvas, obj: ShapeObject, w: number, h: number) {
|
|
1952
|
+
const state = this.objectStates.get(obj.id);
|
|
1953
|
+
const geometry = state.geometry || obj.geometry;
|
|
1954
|
+
const style = state.style || obj.style;
|
|
1955
|
+
|
|
1956
|
+
const path = Skia.Path.Make();
|
|
1957
|
+
if (geometry.type === 'Rectangle') {
|
|
1958
|
+
const rect = { x: -w/2, y: -h/2, width: w, height: h };
|
|
1959
|
+
if (state.cornerRadius || geometry.corner_radius) {
|
|
1960
|
+
const cr = state.cornerRadius || geometry.corner_radius;
|
|
1961
|
+
path.addRRect(Skia.RRectXY(rect, cr, cr));
|
|
1962
|
+
} else {
|
|
1963
|
+
path.addRect(rect);
|
|
859
1964
|
}
|
|
1965
|
+
} else if (geometry.type === 'Ellipse') {
|
|
1966
|
+
path.addOval({ x: -w/2, y: -h/2, width: w, height: h });
|
|
1967
|
+
} else if (geometry.type === 'Triangle') {
|
|
1968
|
+
path.moveTo(0, -h/2);
|
|
1969
|
+
path.lineTo(w/2, h/2);
|
|
1970
|
+
path.lineTo(-w/2, h/2);
|
|
1971
|
+
path.close();
|
|
1972
|
+
} else if (geometry.type === 'Star') {
|
|
1973
|
+
const ir = geometry.inner_radius || 20;
|
|
1974
|
+
const or = geometry.outer_radius || 50;
|
|
1975
|
+
const sp = geometry.points || 5;
|
|
1976
|
+
for (let i = 0; i < sp * 2; i++) {
|
|
1977
|
+
const a = (i * Math.PI / sp) - (Math.PI / 2);
|
|
1978
|
+
const rad = i % 2 === 0 ? or : ir;
|
|
1979
|
+
const px = rad * Math.cos(a);
|
|
1980
|
+
const py = rad * Math.sin(a);
|
|
1981
|
+
if (i === 0) path.moveTo(px, py);
|
|
1982
|
+
else path.lineTo(px, py);
|
|
1983
|
+
}
|
|
1984
|
+
path.close();
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
if (style.fill) {
|
|
1988
|
+
const paint = Skia.Paint();
|
|
1989
|
+
paint.setColor(Skia.Color(style.fill.color || '#000000'));
|
|
1990
|
+
paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
|
|
1991
|
+
paint.setStyle(PaintStyle.Fill);
|
|
1992
|
+
|
|
1993
|
+
if (style.shadow && style.shadow.opacity > 0) {
|
|
1994
|
+
const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
|
|
1995
|
+
const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
|
|
1996
|
+
paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
|
|
1997
|
+
style.shadow.offsetX, style.shadow.offsetY,
|
|
1998
|
+
style.shadow.blur, style.shadow.blur,
|
|
1999
|
+
Skia.Color(colorWithAlpha)
|
|
2000
|
+
));
|
|
2001
|
+
}
|
|
2002
|
+
if (style.blur && style.blur.amount > 0) {
|
|
2003
|
+
paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
|
|
2004
|
+
}
|
|
2005
|
+
canvas.drawPath(path, paint);
|
|
2006
|
+
}
|
|
2007
|
+
if (style.stroke) {
|
|
2008
|
+
const paint = Skia.Paint();
|
|
2009
|
+
paint.setColor(Skia.Color(style.stroke.color));
|
|
2010
|
+
paint.setStrokeWidth(style.stroke.width);
|
|
2011
|
+
paint.setAlphaf((state.opacity ?? 1) * (style.stroke.opacity ?? 1));
|
|
2012
|
+
paint.setStyle(PaintStyle.Stroke);
|
|
2013
|
+
canvas.drawPath(path, paint);
|
|
860
2014
|
}
|
|
861
|
-
canvas.restore();
|
|
862
2015
|
}
|
|
863
2016
|
}
|