exodeui-react-native 1.1.0 → 1.2.1

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/engine.ts +129 -52
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exodeui-react-native",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "React Native runtime for ExodeUI animations",
5
5
  "main": "index.js",
6
6
  "files": [
package/src/engine.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter, ClipOp, matchFont, FontStyle, SkFont } from '@shopify/react-native-skia';
1
+ import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter, ClipOp, matchFont, FontStyle, SkFont, TileMode } from '@shopify/react-native-skia';
2
2
  import { Artboard, Animation as SDKAnimation, ShapeObject, StateMachine, State, Fit, Alignment, Layout, LogicNode, LogicOp, Constraint, ComponentEvent } from './types';
3
3
  import { PhysicsEngine, MatterPhysics } from './physics';
4
4
 
@@ -1240,21 +1240,21 @@ export class ExodeUIEngine {
1240
1240
 
1241
1241
  private getWorldTransform(objId: string): { x: number, y: number, scaleX: number, scaleY: number, rotation: number } {
1242
1242
  const state = this.objectStates.get(objId);
1243
- if (!state) return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 };
1244
-
1245
1243
  const obj = this.artboard?.objects.find(o => o.id === objId);
1246
- if (!obj || !obj.parentId) {
1247
- return {
1248
- x: state.x,
1249
- y: state.y,
1250
- scaleX: state.scale_x ?? 1,
1251
- scaleY: state.scale_y ?? 1,
1252
- rotation: state.rotation ?? 0
1253
- };
1244
+ if (!obj) return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 };
1245
+
1246
+ const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
1247
+ const lx = state?.x !== undefined ? state.x : transform.x;
1248
+ const ly = state?.y !== undefined ? state.y : transform.y;
1249
+ const lsX = state?.scale_x !== undefined ? state.scale_x : (transform.scale_x ?? 1);
1250
+ const lsY = state?.scale_y !== undefined ? state.scale_y : (transform.scale_y ?? 1);
1251
+ const lRot = state?.rotation !== undefined ? state.rotation : transform.rotation;
1252
+
1253
+ if (!obj.parentId || obj.parentId === 'root') {
1254
+ return { x: lx, y: ly, scaleX: lsX, scaleY: lsY, rotation: lRot };
1254
1255
  }
1255
1256
 
1256
1257
  const parentWorld = this.getWorldTransform(obj.parentId);
1257
-
1258
1258
  const rad = parentWorld.rotation * (Math.PI / 180);
1259
1259
  const cos = Math.cos(rad);
1260
1260
  const sin = Math.sin(rad);
@@ -1518,23 +1518,27 @@ export class ExodeUIEngine {
1518
1518
 
1519
1519
  private renderObject(canvas: any, obj: ShapeObject) {
1520
1520
  const state = this.objectStates.get(obj.id);
1521
- if (!state || state.visible === false) return;
1521
+ const isVisible = state?.visible !== undefined ? state.visible : obj.isVisible !== false;
1522
+ if (!isVisible) return;
1522
1523
 
1523
- const geometry = state.geometry || obj.geometry;
1524
- const w = state.width || (geometry as any).width || 0;
1525
- const h = state.height || (geometry as any).height || 0;
1526
- const cx = state.x || 0;
1527
- const cy = state.y || 0;
1528
- const rotation = state.rotation || 0;
1529
- const scaleX = state.scale_x === undefined ? 1 : state.scale_x;
1530
- const scaleY = state.scale_y === undefined ? 1 : state.scale_y;
1524
+ const geometry = state?.geometry || obj.geometry;
1525
+ const w = state?.width || (geometry as any).width || 0;
1526
+ const h = state?.height || (geometry as any).height || 0;
1527
+
1528
+ // Resolve transform: use state if present, otherwise use object defaults
1529
+ const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
1530
+ const cx = state?.x !== undefined ? state.x : transform.x;
1531
+ const cy = state?.y !== undefined ? state.y : transform.y;
1532
+ const rotation = state?.rotation !== undefined ? state.rotation : transform.rotation;
1533
+ const scaleX = state?.scale_x !== undefined ? state.scale_x : (transform.scale_x ?? 1);
1534
+ const scaleY = state?.scale_y !== undefined ? state.scale_y : (transform.scale_y ?? 1);
1531
1535
 
1532
1536
  canvas.save();
1533
1537
  canvas.translate(cx, cy);
1534
- canvas.rotate(rotation * Math.PI / 180, 0, 0);
1535
- canvas.scale(scaleX, scaleY);
1538
+ if (rotation !== 0) canvas.rotate(rotation * Math.PI / 180, 0, 0);
1539
+ if (scaleX !== 1 || scaleY !== 1) canvas.scale(scaleX, scaleY);
1536
1540
 
1537
- if (this._renderCount % 60 === 1) {
1541
+ if (this._renderCount % 120 === 1) {
1538
1542
  console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}`);
1539
1543
  }
1540
1544
 
@@ -1630,13 +1634,40 @@ export class ExodeUIEngine {
1630
1634
  private renderText(canvas: any, obj: any, w: number, h: number) {
1631
1635
  const state = this.objectStates.get(obj.id);
1632
1636
  const geom = state?.geometry || obj.geometry;
1637
+ const enableSegments = geom.enableSegments || obj.enableSegments || false;
1638
+ const segments = geom.segments || obj.segments || [];
1639
+
1640
+ if (enableSegments && segments.length > 0) {
1641
+ let offsetX = -w/2;
1642
+ const totalWidth = segments.reduce((acc: number, seg: any) => {
1643
+ const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
1644
+ return acc + font.getTextWidth(seg.text);
1645
+ }, 0);
1646
+
1647
+ const align = geom.textAlign || 'left';
1648
+ if (align === 'center') offsetX = -totalWidth / 2;
1649
+ else if (align === 'right') offsetX = w/2 - totalWidth;
1650
+
1651
+ segments.forEach((seg: any) => {
1652
+ const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
1653
+ const paint = Skia.Paint();
1654
+ const col = seg.fill?.color || geom.fill?.color || '#ffffff';
1655
+ try { paint.setColor(Skia.Color(col.replace(/\s+/g, ''))); } catch { paint.setColor(Skia.Color('#ffffff')); }
1656
+ paint.setAlphaf((state?.opacity ?? 1) * (seg.fill?.opacity ?? 1));
1657
+
1658
+ canvas.drawText(seg.text, offsetX, 0, paint, font);
1659
+ offsetX += font.getTextWidth(seg.text);
1660
+ });
1661
+ return;
1662
+ }
1663
+
1633
1664
  const text = obj.text || geom.text || '';
1634
1665
  if (!text) return;
1635
1666
 
1636
1667
  const paint = Skia.Paint();
1637
1668
  const colorValue = obj.style?.fill?.color || '#ffffff';
1638
1669
  try {
1639
- const skColor = Skia.Color(colorValue);
1670
+ const skColor = Skia.Color(colorValue.replace(/\s+/g, ''));
1640
1671
  paint.setColor(skColor);
1641
1672
  } catch (e) {
1642
1673
  paint.setColor(Skia.Color('#ffffff'));
@@ -1647,26 +1678,16 @@ export class ExodeUIEngine {
1647
1678
  paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
1648
1679
 
1649
1680
  const fontSize = geom.fontSize || 14;
1650
- const font = this.getFont(fontSize, geom.fontFamily || obj.fontFamily || 'System');
1681
+ const font = this.getFont(fontSize, geom.fontFamily || obj.fontFamily || 'Helvetica Neue');
1651
1682
 
1652
1683
  const align = geom.textAlign || obj.textAlign || 'center';
1653
- let x = -w/2;
1654
-
1655
- // Calculate text width for alignment
1656
1684
  const textWidth = font.getTextWidth(text);
1657
- if (align === 'center') {
1658
- x = -textWidth / 2;
1659
- } else if (align === 'right') {
1660
- x = w/2 - textWidth;
1661
- }
1685
+ let x = -w/2;
1686
+ if (align === 'center') x = -textWidth / 2;
1687
+ else if (align === 'right') x = w/2 - textWidth;
1662
1688
 
1663
- if (this._renderCount % 60 === 1) {
1664
- console.log(`[ExodeUIEngine] Drawing text: "${text}" align=${align} width=${textWidth} x=${x} color=${colorValue} alpha=${paint.getAlphaf()} font_size=${font.getSize()}`);
1665
- }
1666
-
1667
- // Check baseline (web typically aligns center of text roughly to center if no baseline is provided, but standard is y=fontSize/2 or similar)
1689
+ // Check baseline
1668
1690
  const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 3 : fontSize / 2;
1669
-
1670
1691
  canvas.drawText(text, x, y, paint, font);
1671
1692
  }
1672
1693
 
@@ -2084,12 +2105,49 @@ export class ExodeUIEngine {
2084
2105
  const svgContent = geometry.svgContent;
2085
2106
  if (!svgContent) return;
2086
2107
 
2087
- // In a real implementation, we'd parse the SVG or use a pre-parsed Skia Path
2088
- // For now, placeholder or basic path if it's a simple icon
2089
- const paint = Skia.Paint();
2090
- paint.setColor(Skia.Color('#ffffff'));
2091
- paint.setAlphaf(0.8);
2092
- canvas.drawCircle(0, 0, Math.min(w, h) / 4, paint);
2108
+ // Extract path data from potentially multiple <path d="..."> or d='...' tags
2109
+ const pathMatches = svgContent.matchAll(/d=["']([^"']+)["']/g);
2110
+ const paths: string[] = [];
2111
+ for (const match of pathMatches) {
2112
+ if (match[1]) paths.push(match[1]);
2113
+ }
2114
+
2115
+ if (paths.length === 0) return;
2116
+
2117
+ const state = this.objectStates.get(obj.id);
2118
+ const style = state?.style || obj.style || {};
2119
+
2120
+ paths.forEach(d => {
2121
+ try {
2122
+ const path = Skia.Path.MakeFromSVGString(d);
2123
+ if (!path) return;
2124
+
2125
+ // Draw fill
2126
+ if (style.fill && style.fill.type !== 'None') {
2127
+ const paint = Skia.Paint();
2128
+ const fillCol = style.fill.color || '#ffffff';
2129
+ const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#ffffff';
2130
+ try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
2131
+ paint.setAlphaf((state?.opacity ?? 1) * (style.fill.opacity ?? 1));
2132
+ paint.setStyle(PaintStyle.Fill);
2133
+ canvas.drawPath(path, paint);
2134
+ }
2135
+
2136
+ // Draw stroke if enabled
2137
+ if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false) {
2138
+ const paint = Skia.Paint();
2139
+ const strokeCol = style.stroke.color || '#000000';
2140
+ const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
2141
+ try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2142
+ paint.setStrokeWidth(style.stroke.width);
2143
+ paint.setAlphaf((state?.opacity ?? 1) * (style.stroke.opacity ?? 1));
2144
+ paint.setStyle(PaintStyle.Stroke);
2145
+ canvas.drawPath(path, paint);
2146
+ }
2147
+ } catch (e) {
2148
+ console.warn(`[ExodeUIEngine] Failed to parse SVG path for ${obj.id}:`, e);
2149
+ }
2150
+ });
2093
2151
  }
2094
2152
 
2095
2153
  private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
@@ -2284,27 +2342,46 @@ export class ExodeUIEngine {
2284
2342
  if (style.fill) {
2285
2343
  const paint = Skia.Paint();
2286
2344
 
2287
- let hasImageFill = false;
2288
- if ((style.fill.type === 'Image' || style.fill.type === 'Pattern' || style.fill.url) && style.fill.url) {
2345
+ let hasShader = false;
2346
+ if (style.fill.type === 'LinearGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2347
+ const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2348
+ const offsets = style.fill.stops.map((s: any) => s.offset);
2349
+ const start = style.fill.start ? { x: -w/2 + style.fill.start[0] * w, y: -h/2 + style.fill.start[1] * h } : { x: -w/2, y: 0 };
2350
+ const end = style.fill.end ? { x: -w/2 + style.fill.end[0] * w, y: -h/2 + style.fill.end[1] * h } : { x: w/2, y: 0 };
2351
+ paint.setShader(Skia.Shader.MakeLinearGradient(Skia.Point(start.x, start.y), Skia.Point(end.x, end.y), colors, offsets, TileMode.Clamp));
2352
+ hasShader = true;
2353
+ } else if (style.fill.type === 'RadialGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2354
+ const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2355
+ const offsets = style.fill.stops.map((s: any) => s.offset);
2356
+ const center = style.fill.center ? { x: -w/2 + style.fill.center[0] * w, y: -h/2 + style.fill.center[1] * h } : { x: 0, y: 0 };
2357
+ const radius = (style.fill.radius || 0.5) * Math.max(w, h);
2358
+ paint.setShader(Skia.Shader.MakeRadialGradient(Skia.Point(center.x, center.y), radius, colors, offsets, TileMode.Clamp));
2359
+ hasShader = true;
2360
+ } else if ((style.fill.type === 'Image' || style.fill.type === 'Pattern') && style.fill.url) {
2289
2361
  const img = this.getOrDecodeImage(style.fill.url);
2290
2362
  if (img) {
2291
2363
  const matrix = Skia.Matrix();
2292
2364
  matrix.translate(-w/2, -h/2);
2293
2365
  matrix.scale(w / img.width(), h / img.height());
2294
2366
  paint.setShader(img.makeShaderOptions(0, 0, 0, 0, matrix));
2295
- hasImageFill = true;
2367
+ hasShader = true;
2296
2368
  }
2297
2369
  }
2298
2370
 
2299
- if (!hasImageFill) {
2300
- const fillCol = style.fill.color;
2371
+ if (!hasShader) {
2372
+ const fillCol = style.fill.color || '#000000';
2301
2373
  const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#000000';
2302
2374
  try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2303
2375
  }
2304
2376
 
2305
- paint.setAlphaf(Math.max(0, Math.min(1, (state.opacity ?? 1) * (style.fill.opacity ?? 1))));
2377
+ paint.setAlphaf(Math.max(0, Math.min(1, (state?.opacity ?? 1) * (style.fill.opacity ?? 1))));
2306
2378
  paint.setStyle(PaintStyle.Fill);
2307
2379
 
2380
+ // Apply clipping for Frame types before drawing
2381
+ if (obj.type === 'Frame' || (obj as any).clipContent) {
2382
+ canvas.clipRect({ x: -w/2, y: -h/2, width: w, height: h }, ClipOp.Intersect, true);
2383
+ }
2384
+
2308
2385
  if (style.shadow && style.shadow.opacity > 0) {
2309
2386
  const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
2310
2387
  const colorWithAlpha = `${style.shadow.color}${alphaHex}`;