exodeui-react-native 1.2.1 → 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 +471 -143
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;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
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 };
|
|
1239
1309
|
}
|
|
1240
1310
|
|
|
1241
|
-
private
|
|
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 };
|
|
@@ -1535,16 +1649,28 @@ export class ExodeUIEngine {
|
|
|
1535
1649
|
|
|
1536
1650
|
canvas.save();
|
|
1537
1651
|
canvas.translate(cx, cy);
|
|
1538
|
-
|
|
1539
|
-
|
|
1652
|
+
|
|
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
|
+
|
|
1658
|
+
if (rotation !== 0) {
|
|
1659
|
+
// React Native Skia rotate expects degrees
|
|
1660
|
+
canvas.rotate(rotation, 0, 0);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
1664
|
+
canvas.scale(scaleX, scaleY);
|
|
1665
|
+
}
|
|
1540
1666
|
|
|
1541
1667
|
if (this._renderCount % 120 === 1) {
|
|
1542
|
-
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)}`);
|
|
1543
1669
|
}
|
|
1544
1670
|
|
|
1545
|
-
if (geometry.type === 'Text') {
|
|
1671
|
+
if (geometry && geometry.type === 'Text') {
|
|
1546
1672
|
this.renderText(canvas, obj, w, h);
|
|
1547
|
-
} else if (geometry.type === 'Image') {
|
|
1673
|
+
} else if (geometry && geometry.type === 'Image') {
|
|
1548
1674
|
this.renderImage(canvas, obj, w, h);
|
|
1549
1675
|
} else if (obj.type === 'Component' && (obj as any).variant === 'button') {
|
|
1550
1676
|
this.renderButton(canvas, obj, w, h);
|
|
@@ -1638,7 +1764,7 @@ export class ExodeUIEngine {
|
|
|
1638
1764
|
const segments = geom.segments || obj.segments || [];
|
|
1639
1765
|
|
|
1640
1766
|
if (enableSegments && segments.length > 0) {
|
|
1641
|
-
let offsetX =
|
|
1767
|
+
let offsetX = 0;
|
|
1642
1768
|
const totalWidth = segments.reduce((acc: number, seg: any) => {
|
|
1643
1769
|
const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
|
|
1644
1770
|
return acc + font.getTextWidth(seg.text);
|
|
@@ -1646,49 +1772,104 @@ export class ExodeUIEngine {
|
|
|
1646
1772
|
|
|
1647
1773
|
const align = geom.textAlign || 'left';
|
|
1648
1774
|
if (align === 'center') offsetX = -totalWidth / 2;
|
|
1649
|
-
else if (align === 'right') offsetX = w/2 - totalWidth;
|
|
1775
|
+
else if (align === 'right') offsetX = w / 2 - totalWidth;
|
|
1776
|
+
else offsetX = -w / 2; // Default left
|
|
1650
1777
|
|
|
1651
1778
|
segments.forEach((seg: any) => {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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
|
+
});
|
|
1661
1820
|
return;
|
|
1662
1821
|
}
|
|
1663
1822
|
|
|
1664
1823
|
const text = obj.text || geom.text || '';
|
|
1665
1824
|
if (!text) return;
|
|
1666
1825
|
|
|
1826
|
+
const fontSize = geom.fontSize || 14;
|
|
1827
|
+
const fontFamily = geom.fontFamily || obj.fontFamily || 'Helvetica Neue';
|
|
1667
1828
|
const paint = Skia.Paint();
|
|
1668
|
-
const
|
|
1669
|
-
|
|
1670
|
-
const skColor = Skia.Color(colorValue.replace(/\s+/g, ''));
|
|
1671
|
-
paint.setColor(skColor);
|
|
1672
|
-
} catch (e) {
|
|
1673
|
-
paint.setColor(Skia.Color('#ffffff'));
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
const opacity1 = state?.opacity !== undefined ? state.opacity : 1;
|
|
1677
|
-
const opacity2 = obj.style?.fill?.opacity !== undefined ? obj.style.fill.opacity : 1;
|
|
1678
|
-
paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
|
|
1829
|
+
const style = state?.style || obj.style || {};
|
|
1830
|
+
const opacity = state?.opacity ?? 1;
|
|
1679
1831
|
|
|
1680
|
-
const
|
|
1681
|
-
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')); }
|
|
1682
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);
|
|
1683
1852
|
const align = geom.textAlign || obj.textAlign || 'center';
|
|
1684
1853
|
const textWidth = font.getTextWidth(text);
|
|
1685
|
-
let x =
|
|
1854
|
+
let x = 0;
|
|
1686
1855
|
if (align === 'center') x = -textWidth / 2;
|
|
1687
|
-
else if (align === 'right') x = w/2 - textWidth;
|
|
1856
|
+
else if (align === 'right') x = w / 2 - textWidth;
|
|
1857
|
+
else x = -w / 2; // Default left
|
|
1688
1858
|
|
|
1689
1859
|
// Check baseline
|
|
1690
|
-
const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize /
|
|
1860
|
+
const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 2 : fontSize - h / 2;
|
|
1691
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
|
+
}
|
|
1692
1873
|
}
|
|
1693
1874
|
|
|
1694
1875
|
private getOrDecodeImage(src: string): any {
|
|
@@ -1716,21 +1897,74 @@ export class ExodeUIEngine {
|
|
|
1716
1897
|
const geom = state?.geometry || obj.geometry;
|
|
1717
1898
|
const src = obj.src || geom.src || '';
|
|
1718
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
|
+
|
|
1719
1921
|
const img = this.getOrDecodeImage(src);
|
|
1720
1922
|
if (img) {
|
|
1721
1923
|
const paint = Skia.Paint();
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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
|
+
}
|
|
1729
1959
|
} else {
|
|
1730
1960
|
// Fallback placeholder
|
|
1731
1961
|
const paint = Skia.Paint();
|
|
1732
1962
|
paint.setColor(Skia.Color('#374151'));
|
|
1733
|
-
|
|
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
|
+
}
|
|
1734
1968
|
}
|
|
1735
1969
|
}
|
|
1736
1970
|
|
|
@@ -2106,48 +2340,84 @@ export class ExodeUIEngine {
|
|
|
2106
2340
|
if (!svgContent) return;
|
|
2107
2341
|
|
|
2108
2342
|
// Extract path data from potentially multiple <path d="..."> or d='...' tags
|
|
2109
|
-
|
|
2343
|
+
// Use [\s\S] to match across newlines
|
|
2344
|
+
const pathMatches = svgContent.matchAll(/d=["']([\s\S]+?)["']/g);
|
|
2110
2345
|
const paths: string[] = [];
|
|
2111
2346
|
for (const match of pathMatches) {
|
|
2112
|
-
if (match[1]) paths.push(match[1]);
|
|
2347
|
+
if (match[1]) paths.push(match[1].trim());
|
|
2113
2348
|
}
|
|
2114
2349
|
|
|
2115
2350
|
if (paths.length === 0) return;
|
|
2116
2351
|
|
|
2117
2352
|
const state = this.objectStates.get(obj.id);
|
|
2118
2353
|
const style = state?.style || obj.style || {};
|
|
2354
|
+
const opacity = state?.opacity ?? 1;
|
|
2355
|
+
|
|
2356
|
+
canvas.save();
|
|
2357
|
+
canvas.translate(-w / 2, -h / 2);
|
|
2119
2358
|
|
|
2120
2359
|
paths.forEach(d => {
|
|
2121
2360
|
try {
|
|
2122
2361
|
const path = Skia.Path.MakeFromSVGString(d);
|
|
2123
2362
|
if (!path) return;
|
|
2124
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
|
+
|
|
2125
2380
|
// Draw fill
|
|
2126
2381
|
if (style.fill && style.fill.type !== 'None') {
|
|
2127
2382
|
const paint = Skia.Paint();
|
|
2128
2383
|
const fillCol = style.fill.color || '#ffffff';
|
|
2129
2384
|
const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#ffffff';
|
|
2130
2385
|
try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
|
|
2131
|
-
paint
|
|
2386
|
+
applyStyles(paint);
|
|
2132
2387
|
paint.setStyle(PaintStyle.Fill);
|
|
2133
2388
|
canvas.drawPath(path, paint);
|
|
2134
2389
|
}
|
|
2135
2390
|
|
|
2136
2391
|
// Draw stroke if enabled
|
|
2137
|
-
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') {
|
|
2138
2393
|
const paint = Skia.Paint();
|
|
2139
2394
|
const strokeCol = style.stroke.color || '#000000';
|
|
2140
2395
|
const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
|
|
2141
2396
|
try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
|
|
2142
2397
|
paint.setStrokeWidth(style.stroke.width);
|
|
2143
|
-
paint
|
|
2398
|
+
applyStyles(paint);
|
|
2144
2399
|
paint.setStyle(PaintStyle.Stroke);
|
|
2145
|
-
|
|
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
|
+
}
|
|
2146
2411
|
}
|
|
2147
2412
|
} catch (e) {
|
|
2148
|
-
|
|
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
|
+
}
|
|
2149
2417
|
}
|
|
2150
2418
|
});
|
|
2419
|
+
|
|
2420
|
+
canvas.restore();
|
|
2151
2421
|
}
|
|
2152
2422
|
|
|
2153
2423
|
private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
|
|
@@ -2304,27 +2574,53 @@ export class ExodeUIEngine {
|
|
|
2304
2574
|
}
|
|
2305
2575
|
private renderShape(canvas: SkCanvas, obj: ShapeObject, w: number, h: number) {
|
|
2306
2576
|
const state = this.objectStates.get(obj.id);
|
|
2307
|
-
const geometry = state
|
|
2308
|
-
const style = state
|
|
2577
|
+
const geometry = state?.geometry || obj.geometry || {};
|
|
2578
|
+
const style = state?.style || obj.style || {};
|
|
2309
2579
|
|
|
2310
2580
|
const path = Skia.Path.Make();
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
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);
|
|
2317
2613
|
} else {
|
|
2318
2614
|
path.addRect(rect);
|
|
2319
2615
|
}
|
|
2320
|
-
} else if (geometry.type === 'Ellipse') {
|
|
2321
|
-
path.addOval(
|
|
2322
|
-
} else if (geometry.type === 'Triangle') {
|
|
2323
|
-
path.moveTo(0, -h/2);
|
|
2324
|
-
path.lineTo(w/2, h/2);
|
|
2325
|
-
path.lineTo(-w/2, h/2);
|
|
2616
|
+
} else if (geometry && geometry.type === 'Ellipse') {
|
|
2617
|
+
path.addOval(rect);
|
|
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);
|
|
2326
2622
|
path.close();
|
|
2327
|
-
} else if (geometry.type === 'Star') {
|
|
2623
|
+
} else if (geometry && geometry.type === 'Star') {
|
|
2328
2624
|
const ir = geometry.inner_radius || 20;
|
|
2329
2625
|
const or = geometry.outer_radius || 50;
|
|
2330
2626
|
const sp = geometry.points || 5;
|
|
@@ -2337,6 +2633,18 @@ export class ExodeUIEngine {
|
|
|
2337
2633
|
else path.lineTo(px, py);
|
|
2338
2634
|
}
|
|
2339
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
|
|
2340
2648
|
}
|
|
2341
2649
|
|
|
2342
2650
|
if (style.fill) {
|
|
@@ -2346,14 +2654,14 @@ export class ExodeUIEngine {
|
|
|
2346
2654
|
if (style.fill.type === 'LinearGradient' && style.fill.stops && style.fill.stops.length >= 2) {
|
|
2347
2655
|
const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
|
|
2348
2656
|
const offsets = style.fill.stops.map((s: any) => s.offset);
|
|
2349
|
-
const start = style.fill.start ? { x:
|
|
2350
|
-
const end = style.fill.end ? { x:
|
|
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 };
|
|
2351
2659
|
paint.setShader(Skia.Shader.MakeLinearGradient(Skia.Point(start.x, start.y), Skia.Point(end.x, end.y), colors, offsets, TileMode.Clamp));
|
|
2352
2660
|
hasShader = true;
|
|
2353
2661
|
} else if (style.fill.type === 'RadialGradient' && style.fill.stops && style.fill.stops.length >= 2) {
|
|
2354
2662
|
const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
|
|
2355
2663
|
const offsets = style.fill.stops.map((s: any) => s.offset);
|
|
2356
|
-
const center = style.fill.center ? { 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 };
|
|
2357
2665
|
const radius = (style.fill.radius || 0.5) * Math.max(w, h);
|
|
2358
2666
|
paint.setShader(Skia.Shader.MakeRadialGradient(Skia.Point(center.x, center.y), radius, colors, offsets, TileMode.Clamp));
|
|
2359
2667
|
hasShader = true;
|
|
@@ -2361,7 +2669,7 @@ export class ExodeUIEngine {
|
|
|
2361
2669
|
const img = this.getOrDecodeImage(style.fill.url);
|
|
2362
2670
|
if (img) {
|
|
2363
2671
|
const matrix = Skia.Matrix();
|
|
2364
|
-
matrix.translate(-w/2, -h/2);
|
|
2672
|
+
matrix.translate(-w / 2, -h / 2);
|
|
2365
2673
|
matrix.scale(w / img.width(), h / img.height());
|
|
2366
2674
|
paint.setShader(img.makeShaderOptions(0, 0, 0, 0, matrix));
|
|
2367
2675
|
hasShader = true;
|
|
@@ -2379,7 +2687,7 @@ export class ExodeUIEngine {
|
|
|
2379
2687
|
|
|
2380
2688
|
// Apply clipping for Frame types before drawing
|
|
2381
2689
|
if (obj.type === 'Frame' || (obj as any).clipContent) {
|
|
2382
|
-
canvas.clipRect(
|
|
2690
|
+
canvas.clipRect(rect, ClipOp.Intersect, true);
|
|
2383
2691
|
}
|
|
2384
2692
|
|
|
2385
2693
|
if (style.shadow && style.shadow.opacity > 0) {
|
|
@@ -2394,17 +2702,37 @@ export class ExodeUIEngine {
|
|
|
2394
2702
|
if (style.blur && style.blur.amount > 0) {
|
|
2395
2703
|
paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
|
|
2396
2704
|
}
|
|
2397
|
-
|
|
2705
|
+
if (!path.isEmpty()) {
|
|
2706
|
+
canvas.drawPath(path, paint);
|
|
2707
|
+
}
|
|
2398
2708
|
}
|
|
2399
|
-
|
|
2709
|
+
// Draw Stroke
|
|
2710
|
+
if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
|
|
2400
2711
|
const paint = Skia.Paint();
|
|
2401
|
-
const strokeCol = style.stroke.color;
|
|
2402
|
-
const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
|
|
2403
|
-
try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
|
|
2404
|
-
paint.setStrokeWidth(style.stroke.width ?? 1);
|
|
2405
|
-
paint.setAlphaf(Math.max(0, Math.min(1, (state.opacity ?? 1) * (style.stroke.opacity ?? 1))));
|
|
2406
2712
|
paint.setStyle(PaintStyle.Stroke);
|
|
2407
|
-
|
|
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
|
+
}
|
|
2408
2736
|
}
|
|
2409
2737
|
}
|
|
2410
2738
|
}
|