exodeui-react-native 1.3.0 → 1.3.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/README.md +60 -2
- package/package.json +1 -1
- package/src/engine.ts +464 -148
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ function App() {
|
|
|
30
30
|
}
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
## API
|
|
33
|
+
## API Reference
|
|
34
34
|
|
|
35
35
|
### `<ExodeUIView />`
|
|
36
36
|
|
|
@@ -38,7 +38,65 @@ function App() {
|
|
|
38
38
|
|------|------|---------|-------------|
|
|
39
39
|
| `artboard` | `object` | `undefined` | The JSON animation data object. |
|
|
40
40
|
| `style` | `ViewStyle` | `-` | Styles for the container view. |
|
|
41
|
-
| `onReady` | `(engine:
|
|
41
|
+
| `onReady` | `(engine: ExodeUIEngine) => void` | `undefined` | Callback fired when the engine is initialized. |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
### `ExodeUIEngine` (Accessed via `onReady`)
|
|
46
|
+
|
|
47
|
+
The `engine` instance allows you to interact with the animation, respond to events, and update data at runtime.
|
|
48
|
+
|
|
49
|
+
#### Component Listeners
|
|
50
|
+
Register callbacks for specific interactive components:
|
|
51
|
+
|
|
52
|
+
- `setButtonClickCallback(cb: (name, id) => void)`: Fired when a button is clicked.
|
|
53
|
+
- `setToggleCallback(cb: (name, checked) => void)`: Fired when a toggle switch changes state.
|
|
54
|
+
- `setInputChangeCallback(cb: (name, text) => void)`: Fired when an InputBox text is modified.
|
|
55
|
+
- `setGraphPointClickCallback(cb: (event) => void)`: Fired when a point on a LineGraph is clicked. Provides `pointIndex`, `value`, and `label`.
|
|
56
|
+
- `setTriggerCallback(cb: (trigger, animation) => void)`: Fired when a state machine trigger is activated.
|
|
57
|
+
|
|
58
|
+
#### Runtime Updaters
|
|
59
|
+
Modify artboard state and data dynamically:
|
|
60
|
+
|
|
61
|
+
- `updateInput(nameOrId, value)`: Updates a specific state machine input (Number, Boolean, or Trigger).
|
|
62
|
+
- `updateGraphData(nameOrId, data: number[])`: Updates the dataset for a LineGraph component.
|
|
63
|
+
- `updateObjectOptions(id, options)`: Modifies component-specific options (e.g., list items, dropdown options).
|
|
64
|
+
- `updateConstraint(objectId, index, properties)`: Adjusts constraints (e.g., spring stiffness/damping) at runtime.
|
|
65
|
+
|
|
66
|
+
#### Advanced Functions
|
|
67
|
+
- `setLayout(fit, alignment)`: Change the artboard fit (`Contain`, `Cover`, `Fill`) and alignment.
|
|
68
|
+
- `getActiveStateIds(layerName)`: Returns the currently active state IDs for a state machine layer.
|
|
69
|
+
- `reset()`: Resets all object states and inputs to their artboard defaults.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Examples
|
|
74
|
+
|
|
75
|
+
### Responding to a Button Click
|
|
76
|
+
```tsx
|
|
77
|
+
<ExodeUIView
|
|
78
|
+
artboard={myArtboard}
|
|
79
|
+
onReady={(engine) => {
|
|
80
|
+
engine.setButtonClickCallback((name, id) => {
|
|
81
|
+
console.log(`Button "${name}" clicked!`);
|
|
82
|
+
});
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Feeding Real-time Data to a Graph
|
|
88
|
+
```tsx
|
|
89
|
+
<ExodeUIView
|
|
90
|
+
artboard={myArtboard}
|
|
91
|
+
onReady={(engine) => {
|
|
92
|
+
// Update graph every second
|
|
93
|
+
setInterval(() => {
|
|
94
|
+
const nextData = getLiveSensorData();
|
|
95
|
+
engine.updateGraphData("LiveGraph", nextData);
|
|
96
|
+
}, 1000);
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
```
|
|
42
100
|
|
|
43
101
|
## License
|
|
44
102
|
|
package/package.json
CHANGED
package/src/engine.ts
CHANGED
|
@@ -42,6 +42,9 @@ export class ExodeUIEngine {
|
|
|
42
42
|
private justFiredTriggers: Set<string> = new Set();
|
|
43
43
|
private _lastPointerPos: { x: number; y: number } | null = null;
|
|
44
44
|
private _prevPointerPos: { x: number; y: number } | null = null;
|
|
45
|
+
private _reportedErrors: Set<string> = new Set();
|
|
46
|
+
private activeStates: Map<string, string> = new Map(); // LayerName -> StateId
|
|
47
|
+
private activeTriggers: Map<string, any> = new Map();
|
|
45
48
|
|
|
46
49
|
// Interaction State
|
|
47
50
|
private focusedId: string | null = null;
|
|
@@ -204,11 +207,14 @@ export class ExodeUIEngine {
|
|
|
204
207
|
rotation: transform.rotation ?? 0,
|
|
205
208
|
scale_x: transform.scale_x ?? 1,
|
|
206
209
|
scale_y: transform.scale_y ?? 1,
|
|
207
|
-
width: (obj as any).width
|
|
208
|
-
height: (obj as any).height
|
|
209
|
-
cornerRadius
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
width: (obj as any).width ?? (obj.geometry as any)?.width ?? ((obj.geometry?.type === 'Line') ? (obj.geometry as any).length : 100),
|
|
211
|
+
height: (obj as any).height ?? (obj.geometry as any)?.height ?? ((obj.geometry?.type === 'Line') ? 0.01 : 100),
|
|
212
|
+
// cornerRadius lives in geometry.corner_radius for Shape objects, obj.cornerRadius for Frame/Group
|
|
213
|
+
cornerRadius: (obj.geometry as any)?.corner_radius || (obj.geometry as any)?.cornerRadius || (obj as any).cornerRadius || 0,
|
|
214
|
+
// Opacity is typically in transform.opacity in .exode files
|
|
215
|
+
opacity: (transform as any).opacity ?? obj.opacity ?? 1,
|
|
216
|
+
// Use transform.visible or isVisible as fallback
|
|
217
|
+
visible: (transform as any).visible ?? obj.visible ?? (obj as any).isVisible ?? true,
|
|
212
218
|
blendMode: obj.blendMode || 'Normal',
|
|
213
219
|
style: JSON.parse(JSON.stringify(obj.style || {})),
|
|
214
220
|
geometry: JSON.parse(JSON.stringify(obj.geometry || {})),
|
|
@@ -598,14 +604,6 @@ export class ExodeUIEngine {
|
|
|
598
604
|
return value !== undefined ? value : 0;
|
|
599
605
|
}
|
|
600
606
|
|
|
601
|
-
private activeTriggers: Map<string, {
|
|
602
|
-
triggerId: string;
|
|
603
|
-
animation: SDKAnimation;
|
|
604
|
-
time: number;
|
|
605
|
-
phase: 'entry' | 'hold' | 'exit';
|
|
606
|
-
elapsedHold: number;
|
|
607
|
-
}> = new Map();
|
|
608
|
-
|
|
609
607
|
advance(dt: number) {
|
|
610
608
|
if (!this.artboard) return;
|
|
611
609
|
|
|
@@ -664,9 +662,14 @@ export class ExodeUIEngine {
|
|
|
664
662
|
state.time += dt;
|
|
665
663
|
if (state.time >= state.animation.duration) {
|
|
666
664
|
const trigger = this.artboard?.objects.find(o => o.id === objectId)?.triggers?.find(t => t.id === state.triggerId);
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
665
|
+
if (trigger && (trigger as any).holdUntilRelease) {
|
|
666
|
+
state.phase = 'hold';
|
|
667
|
+
state.elapsedHold = 0;
|
|
668
|
+
state.time = state.animation.duration;
|
|
669
|
+
} else {
|
|
670
|
+
state.phase = 'exit';
|
|
671
|
+
state.time = 0;
|
|
672
|
+
}
|
|
670
673
|
}
|
|
671
674
|
this.applyAnimation(state.animation, state.time);
|
|
672
675
|
} else if (state.phase === 'hold') {
|
|
@@ -792,6 +795,10 @@ export class ExodeUIEngine {
|
|
|
792
795
|
|
|
793
796
|
handlePointerInput(type: string, canvasX: number, canvasY: number, canvasWidth: number, canvasHeight: number) {
|
|
794
797
|
if (!this.artboard) return;
|
|
798
|
+
|
|
799
|
+
if (type === 'click' || type === 'PointerDown') {
|
|
800
|
+
console.log(`[ExodeUIEngine] TOUCH RECEIVED: type=${type}, canvasX=${canvasX.toFixed(1)}, canvasY=${canvasY.toFixed(1)}`);
|
|
801
|
+
}
|
|
795
802
|
|
|
796
803
|
const transform = this.calculateTransform(
|
|
797
804
|
canvasWidth, canvasHeight,
|
|
@@ -800,8 +807,18 @@ export class ExodeUIEngine {
|
|
|
800
807
|
this.layout.alignment
|
|
801
808
|
);
|
|
802
809
|
|
|
803
|
-
|
|
804
|
-
|
|
810
|
+
// Map canvas-relative pixels to Artboard-relative pixels
|
|
811
|
+
// 1. Subtract viewport offset
|
|
812
|
+
const rx = canvasX - transform.tx;
|
|
813
|
+
const ry = canvasY - transform.ty;
|
|
814
|
+
|
|
815
|
+
// 2. Scale to artboard size
|
|
816
|
+
const ax = rx / transform.scaleX;
|
|
817
|
+
const ay = ry / transform.scaleY;
|
|
818
|
+
|
|
819
|
+
// 3. Convert from Top-Left origin to Center origin (-W/2 to W/2)
|
|
820
|
+
const artboardX = ax - (this.artboard.width / 2);
|
|
821
|
+
const artboardY = ay - (this.artboard.height / 2);
|
|
805
822
|
|
|
806
823
|
this._prevPointerPos = this._lastPointerPos;
|
|
807
824
|
this._lastPointerPos = { x: artboardX, y: artboardY };
|
|
@@ -842,20 +859,27 @@ export class ExodeUIEngine {
|
|
|
842
859
|
return false;
|
|
843
860
|
};
|
|
844
861
|
|
|
845
|
-
let
|
|
862
|
+
let topInteractiveHit: any = null;
|
|
863
|
+
let topVisualHit: any = null; // Keep track of the topmost visual hit
|
|
846
864
|
for (let i = this.artboard.objects.length - 1; i >= 0; i--) {
|
|
847
865
|
const obj = this.artboard.objects[i];
|
|
848
866
|
const state = this.objectStates.get(obj.id);
|
|
849
867
|
if (state && state.visible !== false && state.opacity > 0 && this.hitTest(obj, x, y)) {
|
|
850
|
-
if (!
|
|
868
|
+
if (!topVisualHit) { // Assign the first visual hit found (which is the topmost)
|
|
869
|
+
topVisualHit = obj;
|
|
870
|
+
}
|
|
851
871
|
if (objectHandlesEvent(obj, type)) {
|
|
852
|
-
|
|
853
|
-
|
|
872
|
+
topInteractiveHit = obj;
|
|
873
|
+
console.log(`[ExodeUIEngine] Interactive Hit: ${obj.name || obj.id} (${obj.type}) at ${x.toFixed(1)},${y.toFixed(1)}`);
|
|
874
|
+
break; // Stop at the first interactive hit (topmost interactive)
|
|
854
875
|
}
|
|
855
876
|
}
|
|
856
877
|
}
|
|
878
|
+
hitObj = topInteractiveHit;
|
|
857
879
|
|
|
858
|
-
if (!hitObj &&
|
|
880
|
+
if (!hitObj && topVisualHit) { // If no interactive hit, use the topmost visual hit
|
|
881
|
+
hitObj = topVisualHit;
|
|
882
|
+
}
|
|
859
883
|
|
|
860
884
|
if (hitObj) {
|
|
861
885
|
console.log(`[ExodeUIEngine] Hit object: ${hitObj.name || hitObj.id} (${hitObj.type})`);
|
|
@@ -902,7 +926,7 @@ export class ExodeUIEngine {
|
|
|
902
926
|
const opts = state.options || {};
|
|
903
927
|
const w = state.width || (activeObj.geometry as any)?.width || 160;
|
|
904
928
|
const h = state.height || (activeObj.geometry as any)?.height || 40;
|
|
905
|
-
const world = this.
|
|
929
|
+
const world = this.getWorldPos(activeObj.id);
|
|
906
930
|
|
|
907
931
|
if (hitId !== this.activeDropdownId) {
|
|
908
932
|
// Check if click is inside the expanded options list
|
|
@@ -1046,7 +1070,7 @@ export class ExodeUIEngine {
|
|
|
1046
1070
|
const state = this.objectStates.get(targetObj.id);
|
|
1047
1071
|
const objW = state?.width || geom?.width || 200;
|
|
1048
1072
|
const objH = state?.height || geom?.height || 150;
|
|
1049
|
-
const world = this.
|
|
1073
|
+
const world = this.getWorldPos(targetObj.id);
|
|
1050
1074
|
const axisMarginL = 30;
|
|
1051
1075
|
const axisMarginB = 20;
|
|
1052
1076
|
const plotW = objW - axisMarginL;
|
|
@@ -1118,7 +1142,7 @@ export class ExodeUIEngine {
|
|
|
1118
1142
|
|
|
1119
1143
|
private updateSliderValueFromPointer(obj: any, x: number, y: number) {
|
|
1120
1144
|
const state = this.objectStates.get(obj.id);
|
|
1121
|
-
const world = this.
|
|
1145
|
+
const world = this.getWorldPos(obj.id);
|
|
1122
1146
|
const opts = state?.options || obj.options || {};
|
|
1123
1147
|
const w = obj.geometry?.width || 200;
|
|
1124
1148
|
|
|
@@ -1192,7 +1216,7 @@ export class ExodeUIEngine {
|
|
|
1192
1216
|
let padVal = opts.padding ?? 0;
|
|
1193
1217
|
if (typeof padVal !== 'number') padVal = padVal[0] || 0;
|
|
1194
1218
|
|
|
1195
|
-
const worldTransform = this.
|
|
1219
|
+
const worldTransform = this.getWorldPos(obj.id);
|
|
1196
1220
|
const dx = x - worldTransform.x;
|
|
1197
1221
|
const dy = y - worldTransform.y;
|
|
1198
1222
|
const rad = -(worldTransform.rotation) * (Math.PI / 180);
|
|
@@ -1225,53 +1249,116 @@ export class ExodeUIEngine {
|
|
|
1225
1249
|
const state = this.objectStates.get(obj.id);
|
|
1226
1250
|
if (!state || state.visible === false) return false;
|
|
1227
1251
|
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
const dx = x - world.x;
|
|
1233
|
-
const dy = y - world.y;
|
|
1252
|
+
// Transform artboard coordinates into object-local coordinates by
|
|
1253
|
+
// recursively applying inverse transforms from root to current object.
|
|
1254
|
+
const local = this.toLocal(obj.id, x, y);
|
|
1234
1255
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1256
|
+
const w = state.width || (obj.geometry as any)?.width || 100;
|
|
1257
|
+
const h = state.height || (obj.geometry as any)?.height || 100;
|
|
1258
|
+
|
|
1259
|
+
// In local space, origin is (0,0) and bounds are [-w/2, -h/2, w/2, h/2]
|
|
1260
|
+
return Math.abs(local.x) <= w / 2 && Math.abs(local.y) <= h / 2;
|
|
1239
1261
|
}
|
|
1240
1262
|
|
|
1241
|
-
|
|
1263
|
+
/**
|
|
1264
|
+
* Transforms a point from Artboard space to Local space of the specified object.
|
|
1265
|
+
*/
|
|
1266
|
+
private toLocal(objId: string, x: number, y: number): { x: number, y: number } {
|
|
1267
|
+
const obj = this.artboard?.objects.find(o => o.id === objId);
|
|
1268
|
+
if (!obj) return { x, y };
|
|
1269
|
+
|
|
1270
|
+
// Build path from root to object
|
|
1271
|
+
const path: ShapeObject[] = [];
|
|
1272
|
+
let curr: any = obj;
|
|
1273
|
+
const processedIds = new Set();
|
|
1274
|
+
while (curr && !processedIds.has(curr.id)) {
|
|
1275
|
+
processedIds.add(curr.id);
|
|
1276
|
+
path.unshift(curr);
|
|
1277
|
+
if (!curr.parentId || curr.parentId === 'root') break;
|
|
1278
|
+
curr = this.artboard?.objects.find(o => o.id === curr.parentId);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
let lx = x;
|
|
1282
|
+
let ly = y;
|
|
1283
|
+
|
|
1284
|
+
for (const node of path) {
|
|
1285
|
+
const s = this.objectStates.get(node.id);
|
|
1286
|
+
const t = node.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
|
|
1287
|
+
|
|
1288
|
+
const nx = s?.x ?? t.x ?? 0;
|
|
1289
|
+
const ny = s?.y ?? t.y ?? 0;
|
|
1290
|
+
const nr = (s?.rotation ?? t.rotation ?? 0) * Math.PI / 180;
|
|
1291
|
+
const nsX = s?.scale_x ?? t.scale_x ?? 1;
|
|
1292
|
+
const nsY = s?.scale_y ?? t.scale_y ?? 1;
|
|
1293
|
+
|
|
1294
|
+
// Inverse Translate
|
|
1295
|
+
const dx = lx - nx;
|
|
1296
|
+
const dy = ly - ny;
|
|
1297
|
+
|
|
1298
|
+
// Inverse Rotate
|
|
1299
|
+
const cos = Math.cos(-nr);
|
|
1300
|
+
const sin = Math.sin(-nr);
|
|
1301
|
+
const rx = dx * cos - dy * sin;
|
|
1302
|
+
const ry = dx * sin + dy * cos;
|
|
1303
|
+
|
|
1304
|
+
// Inverse Scale
|
|
1305
|
+
lx = rx / (nsX || 1);
|
|
1306
|
+
ly = ry / (nsY || 1);
|
|
1307
|
+
}
|
|
1308
|
+
return { x: lx, y: ly };
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
private getWorldMatrix(objId: string): any {
|
|
1242
1312
|
const state = this.objectStates.get(objId);
|
|
1243
1313
|
const obj = this.artboard?.objects.find(o => o.id === objId);
|
|
1244
|
-
if (!obj) return
|
|
1314
|
+
if (!obj) return Skia.Matrix();
|
|
1245
1315
|
|
|
1246
1316
|
const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
|
|
1247
|
-
const lx = state?.x
|
|
1248
|
-
const ly = state?.y
|
|
1249
|
-
const lsX = state?.scale_x
|
|
1250
|
-
const lsY = state?.scale_y
|
|
1251
|
-
const lRot = state?.rotation
|
|
1317
|
+
const lx = state?.x ?? transform.x ?? 0;
|
|
1318
|
+
const ly = state?.y ?? transform.y ?? 0;
|
|
1319
|
+
const lsX = state?.scale_x ?? transform.scale_x ?? 1;
|
|
1320
|
+
const lsY = state?.scale_y ?? transform.scale_y ?? 1;
|
|
1321
|
+
const lRot = state?.rotation ?? transform.rotation ?? 0;
|
|
1322
|
+
|
|
1323
|
+
const matrix = Skia.Matrix();
|
|
1324
|
+
matrix.translate(lx, ly);
|
|
1325
|
+
matrix.rotate(lRot * Math.PI / 180);
|
|
1326
|
+
matrix.scale(lsX, lsY);
|
|
1252
1327
|
|
|
1253
1328
|
if (!obj.parentId || obj.parentId === 'root') {
|
|
1254
|
-
return
|
|
1329
|
+
return matrix;
|
|
1255
1330
|
}
|
|
1256
1331
|
|
|
1257
|
-
const
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
const sx = state.x * parentWorld.scaleX;
|
|
1263
|
-
const sy = state.y * parentWorld.scaleY;
|
|
1332
|
+
const parentMatrix = this.getWorldMatrix(obj.parentId);
|
|
1333
|
+
parentMatrix.concat(matrix);
|
|
1334
|
+
return parentMatrix;
|
|
1335
|
+
}
|
|
1264
1336
|
|
|
1265
|
-
|
|
1266
|
-
|
|
1337
|
+
private mapPoint(matrix: any, x: number, y: number) {
|
|
1338
|
+
// Handle different Skia versions
|
|
1339
|
+
if (matrix.mapXY) {
|
|
1340
|
+
const p = matrix.mapXY(x, y);
|
|
1341
|
+
return { x: p.x, y: p.y };
|
|
1342
|
+
}
|
|
1343
|
+
// Manual fallback for SkMatrix arrays [m0, m1, m2, m3, m4, m5, m6, m7, m8]
|
|
1344
|
+
const m = matrix as any;
|
|
1345
|
+
const px = (m[0] ?? 1) * x + (m[1] ?? 0) * y + (m[2] ?? 0);
|
|
1346
|
+
const py = (m[3] ?? 0) * x + (m[4] ?? 1) * y + (m[5] ?? 0);
|
|
1347
|
+
const pw = (m[6] ?? 0) * x + (m[7] ?? 0) * y + (m[8] ?? 1) || 1;
|
|
1348
|
+
return { x: px / pw, y: py / pw };
|
|
1349
|
+
}
|
|
1267
1350
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1351
|
+
private getWorldPos(objId: string) {
|
|
1352
|
+
const matrix = this.getWorldMatrix(objId);
|
|
1353
|
+
const center = this.mapPoint(matrix, 0, 0);
|
|
1354
|
+
const unit = this.mapPoint(matrix, 1, 0);
|
|
1355
|
+
|
|
1356
|
+
const dx = unit.x - center.x;
|
|
1357
|
+
const dy = unit.y - center.y;
|
|
1358
|
+
const rotation = Math.atan2(dy, dx) * 180 / Math.PI;
|
|
1359
|
+
const scaleX = Math.sqrt(dx*dx + dy*dy);
|
|
1360
|
+
|
|
1361
|
+
return { x: center.x, y: center.y, rotation, scaleX };
|
|
1275
1362
|
}
|
|
1276
1363
|
|
|
1277
1364
|
private applyAnimation(anim: SDKAnimation, time: number) {
|
|
@@ -1423,10 +1510,36 @@ export class ExodeUIEngine {
|
|
|
1423
1510
|
// Center the Artboard Origin
|
|
1424
1511
|
canvas.translate(abWidth / 2, abHeight / 2);
|
|
1425
1512
|
|
|
1426
|
-
// Clip to artboard bounds
|
|
1513
|
+
// Clip to artboard bounds (honoring corner radius)
|
|
1514
|
+
const abState = this.artboard.objects.find(o => o.id === this.artboard?.objects[0]?.id && o.type === 'Frame')
|
|
1515
|
+
? this.objectStates.get(this.artboard.objects.find(o => o.type === 'Frame')?.id || '')
|
|
1516
|
+
: null;
|
|
1517
|
+
let abCornerR = 0;
|
|
1518
|
+
let abRRect: any = null;
|
|
1519
|
+
const rawAbCr = abState?.cornerRadius;
|
|
1520
|
+
if (rawAbCr) {
|
|
1521
|
+
if (Array.isArray(rawAbCr) && rawAbCr.length >= 4) {
|
|
1522
|
+
abRRect = {
|
|
1523
|
+
rect: { x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight },
|
|
1524
|
+
topLeft: Skia.Point(rawAbCr[0], rawAbCr[0]),
|
|
1525
|
+
topRight: Skia.Point(rawAbCr[1], rawAbCr[1]),
|
|
1526
|
+
bottomRight: Skia.Point(rawAbCr[2], rawAbCr[2]),
|
|
1527
|
+
bottomLeft: Skia.Point(rawAbCr[3], rawAbCr[3]),
|
|
1528
|
+
};
|
|
1529
|
+
} else {
|
|
1530
|
+
abCornerR = Array.isArray(rawAbCr) ? (rawAbCr[0] || 0) : Number(rawAbCr);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1427
1534
|
const clipPath = Skia.Path.Make();
|
|
1428
|
-
|
|
1429
|
-
|
|
1535
|
+
if (abRRect) {
|
|
1536
|
+
clipPath.addRRect(abRRect);
|
|
1537
|
+
} else if (abCornerR > 0) {
|
|
1538
|
+
clipPath.addRRect(Skia.RRectXY({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight }, abCornerR, abCornerR));
|
|
1539
|
+
} else {
|
|
1540
|
+
clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
|
|
1541
|
+
}
|
|
1542
|
+
canvas.clipPath(clipPath, ClipOp.Intersect, true);
|
|
1430
1543
|
|
|
1431
1544
|
this.artboard.objects?.forEach((obj: ShapeObject) => {
|
|
1432
1545
|
if (!obj.parentId) {
|
|
@@ -1518,12 +1631,13 @@ export class ExodeUIEngine {
|
|
|
1518
1631
|
|
|
1519
1632
|
private renderObject(canvas: any, obj: ShapeObject) {
|
|
1520
1633
|
const state = this.objectStates.get(obj.id);
|
|
1521
|
-
const isVisible = state?.visible !== undefined ? state.visible : obj.isVisible
|
|
1634
|
+
const isVisible = (state?.visible !== undefined ? state.visible : obj.visible) ?? obj.isVisible ?? true;
|
|
1522
1635
|
if (!isVisible) return;
|
|
1523
1636
|
|
|
1524
|
-
const geometry = state?.geometry || obj.geometry;
|
|
1525
|
-
|
|
1526
|
-
const
|
|
1637
|
+
const geometry = state?.geometry || obj.geometry || {};
|
|
1638
|
+
// Use ?? so that 0 is a valid value, fall back through multiple sources
|
|
1639
|
+
const w = (state?.width ?? (obj as any).width ?? (geometry as any).width) || 0;
|
|
1640
|
+
const h = (state?.height ?? (obj as any).height ?? (geometry as any).height) || 0;
|
|
1527
1641
|
|
|
1528
1642
|
// Resolve transform: use state if present, otherwise use object defaults
|
|
1529
1643
|
const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
|
|
@@ -1536,26 +1650,27 @@ export class ExodeUIEngine {
|
|
|
1536
1650
|
canvas.save();
|
|
1537
1651
|
canvas.translate(cx, cy);
|
|
1538
1652
|
|
|
1539
|
-
//
|
|
1653
|
+
// Apply Global Opacity
|
|
1654
|
+
const opacity = state?.opacity ?? 1;
|
|
1655
|
+
// Note: SkCanvas doesn't have a direct globalAlpha, we usually apply it to paints.
|
|
1656
|
+
// However, some renderers might benefit from a saveLayer for complex blend modes.
|
|
1657
|
+
|
|
1540
1658
|
if (rotation !== 0) {
|
|
1541
|
-
|
|
1542
|
-
canvas.rotate(rotation
|
|
1543
|
-
canvas.translate(-w / 2, -h / 2);
|
|
1659
|
+
// React Native Skia rotate expects degrees
|
|
1660
|
+
canvas.rotate(rotation, 0, 0);
|
|
1544
1661
|
}
|
|
1545
1662
|
|
|
1546
1663
|
if (scaleX !== 1 || scaleY !== 1) {
|
|
1547
|
-
canvas.translate(w / 2, h / 2);
|
|
1548
1664
|
canvas.scale(scaleX, scaleY);
|
|
1549
|
-
canvas.translate(-w / 2, -h / 2);
|
|
1550
1665
|
}
|
|
1551
1666
|
|
|
1552
1667
|
if (this._renderCount % 120 === 1) {
|
|
1553
|
-
console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}`);
|
|
1668
|
+
console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}, opacity: ${opacity.toFixed(2)}`);
|
|
1554
1669
|
}
|
|
1555
1670
|
|
|
1556
|
-
if (geometry.type === 'Text') {
|
|
1671
|
+
if (geometry && geometry.type === 'Text') {
|
|
1557
1672
|
this.renderText(canvas, obj, w, h);
|
|
1558
|
-
} else if (geometry.type === 'Image') {
|
|
1673
|
+
} else if (geometry && geometry.type === 'Image') {
|
|
1559
1674
|
this.renderImage(canvas, obj, w, h);
|
|
1560
1675
|
} else if (obj.type === 'Component' && (obj as any).variant === 'button') {
|
|
1561
1676
|
this.renderButton(canvas, obj, w, h);
|
|
@@ -1656,52 +1771,105 @@ export class ExodeUIEngine {
|
|
|
1656
1771
|
}, 0);
|
|
1657
1772
|
|
|
1658
1773
|
const align = geom.textAlign || 'left';
|
|
1659
|
-
if (align === 'center') offsetX =
|
|
1660
|
-
else if (align === 'right') offsetX = w - totalWidth;
|
|
1774
|
+
if (align === 'center') offsetX = -totalWidth / 2;
|
|
1775
|
+
else if (align === 'right') offsetX = w / 2 - totalWidth;
|
|
1776
|
+
else offsetX = -w / 2; // Default left
|
|
1661
1777
|
|
|
1662
1778
|
segments.forEach((seg: any) => {
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1779
|
+
const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
|
|
1780
|
+
const paint = Skia.Paint();
|
|
1781
|
+
const style = state?.style || obj.style || {};
|
|
1782
|
+
const opacity = state?.opacity ?? 1;
|
|
1783
|
+
|
|
1784
|
+
const col = seg.fill?.color || geom.fill?.color || '#ffffff';
|
|
1785
|
+
const colStr = typeof col === 'string' ? col.replace(/\s+/g, '') : '#ffffff';
|
|
1786
|
+
try { paint.setColor(Skia.Color(colStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
|
|
1787
|
+
|
|
1788
|
+
// Apply shadow/blur/opacity
|
|
1789
|
+
paint.setAlphaf(opacity * (seg.fill?.opacity ?? geom.fill?.opacity ?? 1));
|
|
1790
|
+
if (style.shadow && style.shadow.opacity > 0) {
|
|
1791
|
+
const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
|
|
1792
|
+
const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
|
|
1793
|
+
paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
|
|
1794
|
+
style.shadow.offsetX, style.shadow.offsetY,
|
|
1795
|
+
style.shadow.blur, style.shadow.blur,
|
|
1796
|
+
Skia.Color(colorWithAlpha)
|
|
1797
|
+
));
|
|
1798
|
+
}
|
|
1799
|
+
if (style.blur && style.blur.amount > 0) {
|
|
1800
|
+
paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
const fontSize = seg.fontSize || geom.fontSize || 14;
|
|
1804
|
+
const y = fontSize - h / 2; // Basic baseline relative to center
|
|
1805
|
+
canvas.drawText(seg.text, offsetX, y, paint, font);
|
|
1806
|
+
|
|
1807
|
+
// Add stroke support for segments
|
|
1808
|
+
if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
|
|
1809
|
+
const strokePaint = Skia.Paint();
|
|
1810
|
+
strokePaint.setStyle(PaintStyle.Stroke);
|
|
1811
|
+
strokePaint.setStrokeWidth(style.stroke.width);
|
|
1812
|
+
const sc = style.stroke.color || '#000000';
|
|
1813
|
+
try { strokePaint.setColor(Skia.Color(sc.replace(/\s+/g, ''))); } catch { strokePaint.setColor(Skia.Color('#000000')); }
|
|
1814
|
+
strokePaint.setAlphaf(opacity * (style.stroke.opacity ?? 1));
|
|
1815
|
+
canvas.drawText(seg.text, offsetX, y, strokePaint, font);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
offsetX += font.getTextWidth(seg.text);
|
|
1819
|
+
});
|
|
1674
1820
|
return;
|
|
1675
1821
|
}
|
|
1676
1822
|
|
|
1677
1823
|
const text = obj.text || geom.text || '';
|
|
1678
1824
|
if (!text) return;
|
|
1679
1825
|
|
|
1826
|
+
const fontSize = geom.fontSize || 14;
|
|
1827
|
+
const fontFamily = geom.fontFamily || obj.fontFamily || 'Helvetica Neue';
|
|
1680
1828
|
const paint = Skia.Paint();
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
const skColor = Skia.Color(colorValue.replace(/\s+/g, ''));
|
|
1684
|
-
paint.setColor(skColor);
|
|
1685
|
-
} catch (e) {
|
|
1686
|
-
paint.setColor(Skia.Color('#ffffff'));
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
const opacity1 = state?.opacity !== undefined ? state.opacity : 1;
|
|
1690
|
-
const opacity2 = obj.style?.fill?.opacity !== undefined ? obj.style.fill.opacity : 1;
|
|
1691
|
-
paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
|
|
1829
|
+
const style = state?.style || obj.style || {};
|
|
1830
|
+
const opacity = state?.opacity ?? 1;
|
|
1692
1831
|
|
|
1693
|
-
const
|
|
1694
|
-
const
|
|
1832
|
+
const fillCol = style.fill?.color || geom.fill?.color || obj.color || geom.color || '#000000';
|
|
1833
|
+
const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#000000';
|
|
1834
|
+
try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
|
|
1695
1835
|
|
|
1836
|
+
// Apply shadow/blur/opacity
|
|
1837
|
+
paint.setAlphaf(opacity * (style.fill?.opacity ?? 1));
|
|
1838
|
+
if (style.shadow && style.shadow.opacity > 0) {
|
|
1839
|
+
const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
|
|
1840
|
+
const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
|
|
1841
|
+
paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
|
|
1842
|
+
style.shadow.offsetX, style.shadow.offsetY,
|
|
1843
|
+
style.shadow.blur, style.shadow.blur,
|
|
1844
|
+
Skia.Color(colorWithAlpha)
|
|
1845
|
+
));
|
|
1846
|
+
}
|
|
1847
|
+
if (style.blur && style.blur.amount > 0) {
|
|
1848
|
+
paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
const font = this.getFont(fontSize, fontFamily);
|
|
1696
1852
|
const align = geom.textAlign || obj.textAlign || 'center';
|
|
1697
1853
|
const textWidth = font.getTextWidth(text);
|
|
1698
1854
|
let x = 0;
|
|
1699
|
-
if (align === 'center') x =
|
|
1700
|
-
else if (align === 'right') x = w - textWidth;
|
|
1855
|
+
if (align === 'center') x = -textWidth / 2;
|
|
1856
|
+
else if (align === 'right') x = w / 2 - textWidth;
|
|
1857
|
+
else x = -w / 2; // Default left
|
|
1701
1858
|
|
|
1702
1859
|
// Check baseline
|
|
1703
|
-
const y = geom.verticalAlign === 'center' || !geom.verticalAlign ?
|
|
1860
|
+
const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 2 : fontSize - h / 2;
|
|
1704
1861
|
canvas.drawText(text, x, y, paint, font);
|
|
1862
|
+
|
|
1863
|
+
// Add stroke support for regular text
|
|
1864
|
+
if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
|
|
1865
|
+
const strokePaint = Skia.Paint();
|
|
1866
|
+
strokePaint.setStyle(PaintStyle.Stroke);
|
|
1867
|
+
strokePaint.setStrokeWidth(style.stroke.width);
|
|
1868
|
+
const sc = style.stroke.color || '#000000';
|
|
1869
|
+
try { strokePaint.setColor(Skia.Color(sc.replace(/\s+/g, ''))); } catch { strokePaint.setColor(Skia.Color('#000000')); }
|
|
1870
|
+
strokePaint.setAlphaf(opacity * (style.stroke.opacity ?? 1));
|
|
1871
|
+
canvas.drawText(text, x, y, strokePaint, font);
|
|
1872
|
+
}
|
|
1705
1873
|
}
|
|
1706
1874
|
|
|
1707
1875
|
private getOrDecodeImage(src: string): any {
|
|
@@ -1729,21 +1897,74 @@ export class ExodeUIEngine {
|
|
|
1729
1897
|
const geom = state?.geometry || obj.geometry;
|
|
1730
1898
|
const src = obj.src || geom.src || '';
|
|
1731
1899
|
|
|
1900
|
+
// Resolve corner radius:
|
|
1901
|
+
const rawCrGeo = geom?.corner_radius ?? geom?.cornerRadius;
|
|
1902
|
+
const rawCrState = state?.cornerRadius;
|
|
1903
|
+
const rawCr = (rawCrState !== undefined && rawCrState !== null) ? rawCrState : rawCrGeo;
|
|
1904
|
+
|
|
1905
|
+
let rRect: any = null;
|
|
1906
|
+
if (rawCr !== undefined && rawCr !== null) {
|
|
1907
|
+
if (Array.isArray(rawCr) && rawCr.length >= 4) {
|
|
1908
|
+
rRect = {
|
|
1909
|
+
rect: Skia.XYWHRect(-w / 2, -h / 2, w, h),
|
|
1910
|
+
topLeft: Skia.Point(rawCr[0], rawCr[0]),
|
|
1911
|
+
topRight: Skia.Point(rawCr[1], rawCr[1]),
|
|
1912
|
+
bottomRight: Skia.Point(rawCr[2], rawCr[2]),
|
|
1913
|
+
bottomLeft: Skia.Point(rawCr[3], rawCr[3]),
|
|
1914
|
+
};
|
|
1915
|
+
} else {
|
|
1916
|
+
const r = Array.isArray(rawCr) ? (rawCr[0] || 0) : Number(rawCr);
|
|
1917
|
+
if (r > 0) rRect = Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1732
1921
|
const img = this.getOrDecodeImage(src);
|
|
1733
1922
|
if (img) {
|
|
1734
1923
|
const paint = Skia.Paint();
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1924
|
+
const opacity = state?.opacity ?? 1;
|
|
1925
|
+
paint.setAlphaf(opacity);
|
|
1926
|
+
|
|
1927
|
+
const style = state?.style || obj.style || {};
|
|
1928
|
+
if (style.shadow && style.shadow.opacity > 0) {
|
|
1929
|
+
const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
|
|
1930
|
+
const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
|
|
1931
|
+
paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
|
|
1932
|
+
style.shadow.offsetX, style.shadow.offsetY,
|
|
1933
|
+
style.shadow.blur, style.shadow.blur,
|
|
1934
|
+
Skia.Color(colorWithAlpha)
|
|
1935
|
+
));
|
|
1936
|
+
}
|
|
1937
|
+
if (style.blur && style.blur.amount > 0) {
|
|
1938
|
+
paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
if (rRect) {
|
|
1942
|
+
canvas.save();
|
|
1943
|
+
canvas.clipRRect(rRect, ClipOp.Intersect, true);
|
|
1944
|
+
canvas.drawImageRect(
|
|
1945
|
+
img,
|
|
1946
|
+
Skia.XYWHRect(0, 0, img.width(), img.height()),
|
|
1947
|
+
Skia.XYWHRect(-w / 2, -h / 2, w, h),
|
|
1948
|
+
paint
|
|
1949
|
+
);
|
|
1950
|
+
canvas.restore();
|
|
1951
|
+
} else {
|
|
1952
|
+
canvas.drawImageRect(
|
|
1953
|
+
img,
|
|
1954
|
+
Skia.XYWHRect(0, 0, img.width(), img.height()),
|
|
1955
|
+
Skia.XYWHRect(-w / 2, -h / 2, w, h),
|
|
1956
|
+
paint
|
|
1957
|
+
);
|
|
1958
|
+
}
|
|
1742
1959
|
} else {
|
|
1743
1960
|
// Fallback placeholder
|
|
1744
1961
|
const paint = Skia.Paint();
|
|
1745
1962
|
paint.setColor(Skia.Color('#374151'));
|
|
1746
|
-
|
|
1963
|
+
if (rRect) {
|
|
1964
|
+
canvas.drawRRect(rRect, paint);
|
|
1965
|
+
} else {
|
|
1966
|
+
canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
|
|
1967
|
+
}
|
|
1747
1968
|
}
|
|
1748
1969
|
}
|
|
1749
1970
|
|
|
@@ -2119,48 +2340,84 @@ export class ExodeUIEngine {
|
|
|
2119
2340
|
if (!svgContent) return;
|
|
2120
2341
|
|
|
2121
2342
|
// Extract path data from potentially multiple <path d="..."> or d='...' tags
|
|
2122
|
-
|
|
2343
|
+
// Use [\s\S] to match across newlines
|
|
2344
|
+
const pathMatches = svgContent.matchAll(/d=["']([\s\S]+?)["']/g);
|
|
2123
2345
|
const paths: string[] = [];
|
|
2124
2346
|
for (const match of pathMatches) {
|
|
2125
|
-
if (match[1]) paths.push(match[1]);
|
|
2347
|
+
if (match[1]) paths.push(match[1].trim());
|
|
2126
2348
|
}
|
|
2127
2349
|
|
|
2128
2350
|
if (paths.length === 0) return;
|
|
2129
2351
|
|
|
2130
2352
|
const state = this.objectStates.get(obj.id);
|
|
2131
2353
|
const style = state?.style || obj.style || {};
|
|
2354
|
+
const opacity = state?.opacity ?? 1;
|
|
2355
|
+
|
|
2356
|
+
canvas.save();
|
|
2357
|
+
canvas.translate(-w / 2, -h / 2);
|
|
2132
2358
|
|
|
2133
2359
|
paths.forEach(d => {
|
|
2134
2360
|
try {
|
|
2135
2361
|
const path = Skia.Path.MakeFromSVGString(d);
|
|
2136
2362
|
if (!path) return;
|
|
2137
2363
|
|
|
2364
|
+
const applyStyles = (paint: SkPaint) => {
|
|
2365
|
+
paint.setAlphaf(opacity * (style.fill?.opacity ?? style.stroke?.opacity ?? 1));
|
|
2366
|
+
if (style.shadow && style.shadow.opacity > 0) {
|
|
2367
|
+
const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
|
|
2368
|
+
const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
|
|
2369
|
+
paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
|
|
2370
|
+
style.shadow.offsetX, style.shadow.offsetY,
|
|
2371
|
+
style.shadow.blur, style.shadow.blur,
|
|
2372
|
+
Skia.Color(colorWithAlpha)
|
|
2373
|
+
));
|
|
2374
|
+
}
|
|
2375
|
+
if (style.blur && style.blur.amount > 0) {
|
|
2376
|
+
paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
|
|
2377
|
+
}
|
|
2378
|
+
};
|
|
2379
|
+
|
|
2138
2380
|
// Draw fill
|
|
2139
2381
|
if (style.fill && style.fill.type !== 'None') {
|
|
2140
2382
|
const paint = Skia.Paint();
|
|
2141
2383
|
const fillCol = style.fill.color || '#ffffff';
|
|
2142
2384
|
const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#ffffff';
|
|
2143
2385
|
try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
|
|
2144
|
-
paint
|
|
2386
|
+
applyStyles(paint);
|
|
2145
2387
|
paint.setStyle(PaintStyle.Fill);
|
|
2146
2388
|
canvas.drawPath(path, paint);
|
|
2147
2389
|
}
|
|
2148
2390
|
|
|
2149
2391
|
// Draw stroke if enabled
|
|
2150
|
-
if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false) {
|
|
2392
|
+
if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
|
|
2151
2393
|
const paint = Skia.Paint();
|
|
2152
2394
|
const strokeCol = style.stroke.color || '#000000';
|
|
2153
2395
|
const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
|
|
2154
2396
|
try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
|
|
2155
2397
|
paint.setStrokeWidth(style.stroke.width);
|
|
2156
|
-
paint
|
|
2398
|
+
applyStyles(paint);
|
|
2157
2399
|
paint.setStyle(PaintStyle.Stroke);
|
|
2158
|
-
|
|
2400
|
+
|
|
2401
|
+
const align = style.stroke.strokeAlign || 'center';
|
|
2402
|
+
if (align === 'inside') {
|
|
2403
|
+
canvas.save();
|
|
2404
|
+
canvas.clipPath(path, ClipOp.Intersect, true);
|
|
2405
|
+
paint.setStrokeWidth(style.stroke.width * 2);
|
|
2406
|
+
canvas.drawPath(path, paint);
|
|
2407
|
+
canvas.restore();
|
|
2408
|
+
} else {
|
|
2409
|
+
canvas.drawPath(path, paint);
|
|
2410
|
+
}
|
|
2159
2411
|
}
|
|
2160
2412
|
} catch (e) {
|
|
2161
|
-
|
|
2413
|
+
if (!this._reportedErrors.has(obj.id)) {
|
|
2414
|
+
console.warn(`[ExodeUIEngine] Failed to parse SVG path for ${obj.id}:`, e);
|
|
2415
|
+
this._reportedErrors.add(obj.id);
|
|
2416
|
+
}
|
|
2162
2417
|
}
|
|
2163
2418
|
});
|
|
2419
|
+
|
|
2420
|
+
canvas.restore();
|
|
2164
2421
|
}
|
|
2165
2422
|
|
|
2166
2423
|
private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
|
|
@@ -2317,39 +2574,77 @@ export class ExodeUIEngine {
|
|
|
2317
2574
|
}
|
|
2318
2575
|
private renderShape(canvas: SkCanvas, obj: ShapeObject, w: number, h: number) {
|
|
2319
2576
|
const state = this.objectStates.get(obj.id);
|
|
2320
|
-
const geometry = state
|
|
2321
|
-
const style = state
|
|
2577
|
+
const geometry = state?.geometry || obj.geometry || {};
|
|
2578
|
+
const style = state?.style || obj.style || {};
|
|
2322
2579
|
|
|
2323
2580
|
const path = Skia.Path.Make();
|
|
2324
|
-
const rect = Skia.XYWHRect(
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2581
|
+
const rect = Skia.XYWHRect(-w / 2, -h / 2, w, h);
|
|
2582
|
+
|
|
2583
|
+
// Resolve corner radius from all possible sources:
|
|
2584
|
+
const rawCrState = state?.cornerRadius;
|
|
2585
|
+
const rawCrGeo = geometry?.corner_radius ?? geometry?.cornerRadius;
|
|
2586
|
+
const rawCr = (rawCrState !== undefined && rawCrState !== null) ? rawCrState : rawCrGeo;
|
|
2587
|
+
|
|
2588
|
+
let rRect: any = null;
|
|
2589
|
+
if (rawCr !== undefined && rawCr !== null) {
|
|
2590
|
+
if (Array.isArray(rawCr) && rawCr.length >= 4) {
|
|
2591
|
+
// Support non-uniform corner radii [tl, tr, br, bl]
|
|
2592
|
+
rRect = {
|
|
2593
|
+
rect,
|
|
2594
|
+
topLeft: Skia.Point(rawCr[0], rawCr[0]),
|
|
2595
|
+
topRight: Skia.Point(rawCr[1], rawCr[1]),
|
|
2596
|
+
bottomRight: Skia.Point(rawCr[2], rawCr[2]),
|
|
2597
|
+
bottomLeft: Skia.Point(rawCr[3], rawCr[3]),
|
|
2598
|
+
};
|
|
2599
|
+
} else {
|
|
2600
|
+
const r = Array.isArray(rawCr) ? (rawCr[0] || 0) : Number(rawCr);
|
|
2601
|
+
if (r > 0) rRect = Skia.RRectXY(rect, r, r);
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
const geoOrObjType = (geometry && geometry.type) || 'Unknown';
|
|
2606
|
+
if (this._renderCount % 120 === 1 && !geometry) {
|
|
2607
|
+
// console.log(`[ExodeUIEngine] Drawing Object without geometry: ${obj.name || obj.id}`);
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
if (geoOrObjType === 'Rectangle') {
|
|
2611
|
+
if (rRect) {
|
|
2612
|
+
path.addRRect(rRect);
|
|
2330
2613
|
} else {
|
|
2331
2614
|
path.addRect(rect);
|
|
2332
2615
|
}
|
|
2333
|
-
} else if (geometry.type === 'Ellipse') {
|
|
2616
|
+
} else if (geometry && geometry.type === 'Ellipse') {
|
|
2334
2617
|
path.addOval(rect);
|
|
2335
|
-
} else if (geometry.type === 'Triangle') {
|
|
2336
|
-
path.moveTo(
|
|
2337
|
-
path.lineTo(w, h);
|
|
2338
|
-
path.lineTo(
|
|
2618
|
+
} else if (geometry && geometry.type === 'Triangle') {
|
|
2619
|
+
path.moveTo(0, -h / 2);
|
|
2620
|
+
path.lineTo(w / 2, h / 2);
|
|
2621
|
+
path.lineTo(-w / 2, h / 2);
|
|
2339
2622
|
path.close();
|
|
2340
|
-
} else if (geometry.type === 'Star') {
|
|
2623
|
+
} else if (geometry && geometry.type === 'Star') {
|
|
2341
2624
|
const ir = geometry.inner_radius || 20;
|
|
2342
2625
|
const or = geometry.outer_radius || 50;
|
|
2343
2626
|
const sp = geometry.points || 5;
|
|
2344
2627
|
for (let i = 0; i < sp * 2; i++) {
|
|
2345
2628
|
const a = (i * Math.PI / sp) - (Math.PI / 2);
|
|
2346
2629
|
const rad = i % 2 === 0 ? or : ir;
|
|
2347
|
-
const px =
|
|
2348
|
-
const py =
|
|
2630
|
+
const px = rad * Math.cos(a);
|
|
2631
|
+
const py = rad * Math.sin(a);
|
|
2349
2632
|
if (i === 0) path.moveTo(px, py);
|
|
2350
2633
|
else path.lineTo(px, py);
|
|
2351
2634
|
}
|
|
2352
2635
|
path.close();
|
|
2636
|
+
} else if (geometry.type === 'Polygon' && geometry.points) {
|
|
2637
|
+
geometry.points.forEach((p: any, i: number) => {
|
|
2638
|
+
if (i === 0) path.moveTo(p.x - w/2, p.y - h/2);
|
|
2639
|
+
else path.lineTo(p.x - w/2, p.y - h/2);
|
|
2640
|
+
});
|
|
2641
|
+
if (geometry.isClosed !== false) path.close();
|
|
2642
|
+
} else if (geometry.type === 'Line') {
|
|
2643
|
+
const len = geometry.length || 0;
|
|
2644
|
+
path.moveTo(-len / 2, 0);
|
|
2645
|
+
path.lineTo(len / 2, 0);
|
|
2646
|
+
} else {
|
|
2647
|
+
// No default geometry added to path for unknown types
|
|
2353
2648
|
}
|
|
2354
2649
|
|
|
2355
2650
|
if (style.fill) {
|
|
@@ -2359,14 +2654,14 @@ export class ExodeUIEngine {
|
|
|
2359
2654
|
if (style.fill.type === 'LinearGradient' && style.fill.stops && style.fill.stops.length >= 2) {
|
|
2360
2655
|
const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
|
|
2361
2656
|
const offsets = style.fill.stops.map((s: any) => s.offset);
|
|
2362
|
-
const start = style.fill.start ? { x: style.fill.start[0] * w, y: style.fill.start[1] * h } : { x:
|
|
2363
|
-
const end = style.fill.end ? { x: style.fill.end[0] * w, y: style.fill.end[1] * h } : { x: w, y:
|
|
2657
|
+
const start = style.fill.start ? { x: (style.fill.start[0] - 0.5) * w, y: (style.fill.start[1] - 0.5) * h } : { x: -w/2, y: -h/2 };
|
|
2658
|
+
const end = style.fill.end ? { x: (style.fill.end[0] - 0.5) * w, y: (style.fill.end[1] - 0.5) * h } : { x: w/2, y: -h/2 };
|
|
2364
2659
|
paint.setShader(Skia.Shader.MakeLinearGradient(Skia.Point(start.x, start.y), Skia.Point(end.x, end.y), colors, offsets, TileMode.Clamp));
|
|
2365
2660
|
hasShader = true;
|
|
2366
2661
|
} else if (style.fill.type === 'RadialGradient' && style.fill.stops && style.fill.stops.length >= 2) {
|
|
2367
2662
|
const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
|
|
2368
2663
|
const offsets = style.fill.stops.map((s: any) => s.offset);
|
|
2369
|
-
const center = style.fill.center ? { x: style.fill.center[0] * w, y: style.fill.center[1] * h } : { x:
|
|
2664
|
+
const center = style.fill.center ? { x: (style.fill.center[0] - 0.5) * w, y: (style.fill.center[1] - 0.5) * h } : { x: 0, y: 0 };
|
|
2370
2665
|
const radius = (style.fill.radius || 0.5) * Math.max(w, h);
|
|
2371
2666
|
paint.setShader(Skia.Shader.MakeRadialGradient(Skia.Point(center.x, center.y), radius, colors, offsets, TileMode.Clamp));
|
|
2372
2667
|
hasShader = true;
|
|
@@ -2374,6 +2669,7 @@ export class ExodeUIEngine {
|
|
|
2374
2669
|
const img = this.getOrDecodeImage(style.fill.url);
|
|
2375
2670
|
if (img) {
|
|
2376
2671
|
const matrix = Skia.Matrix();
|
|
2672
|
+
matrix.translate(-w / 2, -h / 2);
|
|
2377
2673
|
matrix.scale(w / img.width(), h / img.height());
|
|
2378
2674
|
paint.setShader(img.makeShaderOptions(0, 0, 0, 0, matrix));
|
|
2379
2675
|
hasShader = true;
|
|
@@ -2406,17 +2702,37 @@ export class ExodeUIEngine {
|
|
|
2406
2702
|
if (style.blur && style.blur.amount > 0) {
|
|
2407
2703
|
paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
|
|
2408
2704
|
}
|
|
2409
|
-
|
|
2705
|
+
if (!path.isEmpty()) {
|
|
2706
|
+
canvas.drawPath(path, paint);
|
|
2707
|
+
}
|
|
2410
2708
|
}
|
|
2411
|
-
|
|
2709
|
+
// Draw Stroke
|
|
2710
|
+
if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
|
|
2412
2711
|
const paint = Skia.Paint();
|
|
2413
|
-
const strokeCol = style.stroke.color;
|
|
2414
|
-
const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
|
|
2415
|
-
try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
|
|
2416
|
-
paint.setStrokeWidth(style.stroke.width ?? 1);
|
|
2417
|
-
paint.setAlphaf(Math.max(0, Math.min(1, (state.opacity ?? 1) * (style.stroke.opacity ?? 1))));
|
|
2418
2712
|
paint.setStyle(PaintStyle.Stroke);
|
|
2419
|
-
|
|
2713
|
+
paint.setStrokeWidth(style.stroke.width);
|
|
2714
|
+
|
|
2715
|
+
const col = style.stroke.color || '#000000';
|
|
2716
|
+
const colStr = typeof col === 'string' ? col.replace(/\s+/g, '') : '#000000';
|
|
2717
|
+
try { paint.setColor(Skia.Color(colStr)); } catch { paint.setColor(Skia.Color('#000000')); }
|
|
2718
|
+
|
|
2719
|
+
const opacity = state?.opacity ?? 1;
|
|
2720
|
+
paint.setAlphaf(opacity * (style.stroke.opacity ?? 1));
|
|
2721
|
+
|
|
2722
|
+
const align = style.stroke.strokeAlign || 'center';
|
|
2723
|
+
if (align === 'inside') {
|
|
2724
|
+
canvas.save();
|
|
2725
|
+
canvas.clipPath(path, ClipOp.Intersect, true);
|
|
2726
|
+
paint.setStrokeWidth(style.stroke.width * 2);
|
|
2727
|
+
if (!path.isEmpty()) {
|
|
2728
|
+
canvas.drawPath(path, paint);
|
|
2729
|
+
}
|
|
2730
|
+
canvas.restore();
|
|
2731
|
+
} else {
|
|
2732
|
+
if (!path.isEmpty()) {
|
|
2733
|
+
canvas.drawPath(path, paint);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2420
2736
|
}
|
|
2421
2737
|
}
|
|
2422
2738
|
}
|