exodeui-react-native 1.0.10 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/engine.ts +179 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exodeui-react-native",
3
- "version": "1.0.10",
3
+ "version": "1.2.0",
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,35 +1678,60 @@ 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
 
1694
+ private getOrDecodeImage(src: string): any {
1695
+ if (!src) return null;
1696
+ if (this.imageCache.has(src)) return this.imageCache.get(src)!;
1697
+
1698
+ if (src.startsWith('data:image')) {
1699
+ try {
1700
+ const base64Str = src.includes(',') ? src.split(',')[1] : src;
1701
+ const data = Skia.Data.fromBase64(base64Str);
1702
+ const img = Skia.Image.MakeImageFromEncoded(data);
1703
+ if (img) {
1704
+ this.imageCache.set(src, img);
1705
+ return img;
1706
+ }
1707
+ } catch (e) {
1708
+ console.error('[ExodeUIEngine] Failed to decode base64 image:', e);
1709
+ }
1710
+ }
1711
+ return null;
1712
+ }
1713
+
1673
1714
  private renderImage(canvas: any, obj: any, w: number, h: number) {
1674
- // Image support requires Skia.Image.MakeFromExternalSource or similar
1675
- // Placeholder for now
1676
- const paint = Skia.Paint();
1677
- paint.setColor(Skia.Color('#374151'));
1678
- canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
1715
+ const state = this.objectStates.get(obj.id);
1716
+ const geom = state?.geometry || obj.geometry;
1717
+ const src = obj.src || geom.src || '';
1718
+
1719
+ const img = this.getOrDecodeImage(src);
1720
+ if (img) {
1721
+ const paint = Skia.Paint();
1722
+ paint.setAlphaf(state?.opacity ?? 1);
1723
+ canvas.drawImageRect(
1724
+ img,
1725
+ { x: 0, y: 0, width: img.width(), height: img.height() },
1726
+ { x: -w/2, y: -h/2, width: w, height: h },
1727
+ paint
1728
+ );
1729
+ } else {
1730
+ // Fallback placeholder
1731
+ const paint = Skia.Paint();
1732
+ paint.setColor(Skia.Color('#374151'));
1733
+ canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
1734
+ }
1679
1735
  }
1680
1736
 
1681
1737
  private renderButton(canvas: any, obj: any, w: number, h: number) {
@@ -2049,12 +2105,45 @@ export class ExodeUIEngine {
2049
2105
  const svgContent = geometry.svgContent;
2050
2106
  if (!svgContent) return;
2051
2107
 
2052
- // In a real implementation, we'd parse the SVG or use a pre-parsed Skia Path
2053
- // For now, placeholder or basic path if it's a simple icon
2054
- const paint = Skia.Paint();
2055
- paint.setColor(Skia.Color('#ffffff'));
2056
- paint.setAlphaf(0.8);
2057
- canvas.drawCircle(0, 0, Math.min(w, h) / 4, paint);
2108
+ // Extract path data from potentially multiple <path d="..."> tags
2109
+ const pathMatches = svgContent.matchAll(/d="([^"]+)"/g);
2110
+ const paths: string[] = [];
2111
+ for (const match of pathMatches) {
2112
+ paths.push(match[1] as string);
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
+ const path = Skia.Path.MakeFromSVGString(d);
2122
+ if (!path) return;
2123
+
2124
+ // Draw fill
2125
+ if (style.fill && style.fill.type !== 'None') {
2126
+ const paint = Skia.Paint();
2127
+ const fillCol = style.fill.color || '#ffffff';
2128
+ const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#ffffff';
2129
+ try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
2130
+ paint.setAlphaf((state?.opacity ?? 1) * (style.fill.opacity ?? 1));
2131
+ paint.setStyle(PaintStyle.Fill);
2132
+ canvas.drawPath(path, paint);
2133
+ }
2134
+
2135
+ // Draw stroke if enabled
2136
+ if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false) {
2137
+ const paint = Skia.Paint();
2138
+ const strokeCol = style.stroke.color || '#000000';
2139
+ const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
2140
+ try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2141
+ paint.setStrokeWidth(style.stroke.width);
2142
+ paint.setAlphaf((state?.opacity ?? 1) * (style.stroke.opacity ?? 1));
2143
+ paint.setStyle(PaintStyle.Stroke);
2144
+ canvas.drawPath(path, paint);
2145
+ }
2146
+ });
2058
2147
  }
2059
2148
 
2060
2149
  private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
@@ -2248,10 +2337,47 @@ export class ExodeUIEngine {
2248
2337
 
2249
2338
  if (style.fill) {
2250
2339
  const paint = Skia.Paint();
2251
- paint.setColor(Skia.Color(style.fill.color || '#000000'));
2252
- paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
2340
+
2341
+ let hasShader = false;
2342
+ if (style.fill.type === 'LinearGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2343
+ const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2344
+ const offsets = style.fill.stops.map((s: any) => s.offset);
2345
+ 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 };
2346
+ 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 };
2347
+ paint.setShader(Skia.Shader.MakeLinearGradient(Skia.Point(start.x, start.y), Skia.Point(end.x, end.y), colors, offsets, TileMode.Clamp));
2348
+ hasShader = true;
2349
+ } else if (style.fill.type === 'RadialGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2350
+ const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2351
+ const offsets = style.fill.stops.map((s: any) => s.offset);
2352
+ 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 };
2353
+ const radius = (style.fill.radius || 0.5) * Math.max(w, h);
2354
+ paint.setShader(Skia.Shader.MakeRadialGradient(Skia.Point(center.x, center.y), radius, colors, offsets, TileMode.Clamp));
2355
+ hasShader = true;
2356
+ } else if ((style.fill.type === 'Image' || style.fill.type === 'Pattern') && style.fill.url) {
2357
+ const img = this.getOrDecodeImage(style.fill.url);
2358
+ if (img) {
2359
+ const matrix = Skia.Matrix();
2360
+ matrix.translate(-w/2, -h/2);
2361
+ matrix.scale(w / img.width(), h / img.height());
2362
+ paint.setShader(img.makeShaderOptions(0, 0, 0, 0, matrix));
2363
+ hasShader = true;
2364
+ }
2365
+ }
2366
+
2367
+ if (!hasShader) {
2368
+ const fillCol = style.fill.color || '#000000';
2369
+ const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#000000';
2370
+ try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2371
+ }
2372
+
2373
+ paint.setAlphaf(Math.max(0, Math.min(1, (state?.opacity ?? 1) * (style.fill.opacity ?? 1))));
2253
2374
  paint.setStyle(PaintStyle.Fill);
2254
2375
 
2376
+ // Apply clipping for Frame types before drawing
2377
+ if (obj.type === 'Frame' || (obj as any).clipContent) {
2378
+ canvas.clipRect({ x: -w/2, y: -h/2, width: w, height: h }, ClipOp.Intersect, true);
2379
+ }
2380
+
2255
2381
  if (style.shadow && style.shadow.opacity > 0) {
2256
2382
  const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
2257
2383
  const colorWithAlpha = `${style.shadow.color}${alphaHex}`;