exodeui-react-native 1.0.1 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exodeui-react-native",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "React Native runtime for ExodeUI animations",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -2,11 +2,7 @@ import React, { useRef, useEffect, useState, forwardRef, useImperativeHandle } f
2
2
  import { View, StyleSheet, LayoutChangeEvent, ViewStyle, PanResponder, PanResponderInstance, GestureResponderEvent, Image } from 'react-native';
3
3
  import { Canvas, Skia, Picture, SkPicture } from '@shopify/react-native-skia';
4
4
  import { ExodeUIEngine } from './engine';
5
- import { Artboard, Fit, Alignment } from './types';
6
-
7
- // Assuming ComponentEvent is defined elsewhere or will be defined.
8
- // For now, let's define a placeholder if not provided.
9
- type ComponentEvent = any;
5
+ import { Artboard, Fit, Alignment, ComponentEvent } from './types';
10
6
 
11
7
  export interface ExodeUIViewProps {
12
8
  artboard?: Artboard;
@@ -33,6 +29,11 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
33
29
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
34
30
  const lastTimeRef = useRef<number>(0);
35
31
  const rafRef = useRef<number | undefined>(undefined);
32
+ const dimsRef = useRef(dimensions);
33
+
34
+ useEffect(() => {
35
+ dimsRef.current = dimensions;
36
+ }, [dimensions]);
36
37
 
37
38
  useImperativeHandle(ref, () => ({
38
39
  getEngine: () => engineRef.current,
@@ -101,9 +102,11 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
101
102
  }
102
103
 
103
104
  if (data) {
104
- console.log('[ExodeUIView] Loading artboard:', data.name || 'Untitled');
105
+ console.log('[ExodeUIView] Successfully loaded artboard:', data.name || 'Untitled', 'Objects:', data.objects?.length);
105
106
  engineRef.current.load(data);
106
107
  if (onReady) onReady(engineRef.current);
108
+ } else {
109
+ console.warn('[ExodeUIView] ❌ No artboard data found to load');
107
110
  }
108
111
  };
109
112
 
@@ -153,32 +156,41 @@ export const ExodeUIView = forwardRef<any, ExodeUIViewProps>(
153
156
  };
154
157
  }, [autoPlay, dimensions]);
155
158
 
156
- const handleResponderStart = (e: any) => {
157
- const { locationX, locationY } = e.nativeEvent;
158
- engineRef.current.handlePointerInput('PointerDown', locationX, locationY, dimensions.width, dimensions.height);
159
- return true;
160
- };
161
-
162
- const handleResponderRelease = (e: any) => {
163
- const { locationX, locationY } = e.nativeEvent;
164
- engineRef.current.handlePointerInput('click', locationX, locationY, dimensions.width, dimensions.height);
165
- engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimensions.width, dimensions.height);
166
- };
167
-
168
- const handleResponderMove = (e: any) => {
169
- const { locationX, locationY } = e.nativeEvent;
170
- engineRef.current.handlePointerInput('PointerMove', locationX, locationY, dimensions.width, dimensions.height);
171
- };
159
+ const panResponder = useRef(
160
+ PanResponder.create({
161
+ onStartShouldSetPanResponder: () => true,
162
+ onStartShouldSetPanResponderCapture: () => true,
163
+ onMoveShouldSetPanResponder: () => true,
164
+ onMoveShouldSetPanResponderCapture: () => true,
165
+ onPanResponderGrant: (e) => {
166
+ const { locationX, locationY } = e.nativeEvent;
167
+ console.log(`[ExodeUIView] PanResponder Grant: ${locationX.toFixed(1)}, ${locationY.toFixed(1)}`);
168
+ engineRef.current.handlePointerInput('PointerDown', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
169
+ },
170
+ onPanResponderMove: (e) => {
171
+ const { locationX, locationY } = e.nativeEvent;
172
+ engineRef.current.handlePointerInput('PointerMove', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
173
+ },
174
+ onPanResponderRelease: (e) => {
175
+ const { locationX, locationY } = e.nativeEvent;
176
+ console.log(`[ExodeUIView] PanResponder Release: ${locationX.toFixed(1)}, ${locationY.toFixed(1)}`);
177
+ engineRef.current.handlePointerInput('click', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
178
+ engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
179
+ },
180
+ onPanResponderTerminate: (e) => {
181
+ const { locationX, locationY } = e.nativeEvent;
182
+ engineRef.current.handlePointerInput('PointerUp', locationX, locationY, dimsRef.current.width, dimsRef.current.height);
183
+ },
184
+ onShouldBlockNativeResponder: () => true,
185
+ onPanResponderTerminationRequest: () => false,
186
+ })
187
+ ).current;
172
188
 
173
189
  return (
174
190
  <View
175
191
  style={[styles.container, style]}
176
192
  onLayout={onLayout}
177
- onStartShouldSetResponder={() => true}
178
- onResponderGrant={handleResponderStart}
179
- onResponderMove={handleResponderMove}
180
- onResponderRelease={handleResponderRelease}
181
- onResponderTerminate={handleResponderRelease}
193
+ {...panResponder.panHandlers}
182
194
  >
183
195
  <View style={StyleSheet.absoluteFill} pointerEvents="none">
184
196
  <Canvas style={{ flex: 1 }}>
package/src/engine.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter } from '@shopify/react-native-skia';
1
+ import { SkCanvas, SkImage, SkPaint, PaintStyle, Skia, SkPath, SkColor, BlurStyle, SkImageFilter, SkMaskFilter, ClipOp, matchFont, FontStyle, SkFont } 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
 
@@ -11,6 +11,7 @@ export class ExodeUIEngine {
11
11
 
12
12
  // State Machine State
13
13
  private activeStateMachine: StateMachine | null = null;
14
+ private _renderCount: number = 0;
14
15
  private inputs: Map<string, any> = new Map(); // id -> value
15
16
  private inputNameMap: Map<string, string[]> = new Map(); // name -> id[] mapping
16
17
  private layerStates: Map<string, {
@@ -21,6 +22,7 @@ export class ExodeUIEngine {
21
22
  }> = new Map();
22
23
 
23
24
  private imageCache = new Map<string, SkImage>();
25
+ private fonts = new Map<number, SkFont>();
24
26
 
25
27
  private layout: Layout = { fit: 'Contain', alignment: 'Center' };
26
28
 
@@ -36,9 +38,15 @@ export class ExodeUIEngine {
36
38
 
37
39
  // Track triggers that were just fired in the current frame
38
40
  private justFiredTriggers: Set<string> = new Set();
41
+ private _lastPointerPos: { x: number; y: number } | null = null;
42
+ private _prevPointerPos: { x: number; y: number } | null = null;
39
43
 
40
44
  // Interaction State
41
45
  private focusedId: string | null = null;
46
+ private draggingSliderId: string | null = null;
47
+ private activeDropdownId: string | null = null;
48
+ private draggingListViewId: string | null = null;
49
+ private lastHoveredObjectId: string | null = null;
42
50
 
43
51
  // Physics/Animation State
44
52
  private objectVelocities: Map<string, { vx: number; vy: number }> = new Map();
@@ -170,6 +178,7 @@ export class ExodeUIEngine {
170
178
  this.inputs.clear();
171
179
  this.inputNameMap.clear();
172
180
  this.layerStates.clear();
181
+ this.fonts.clear();
173
182
 
174
183
  if (this.physicsEngine) {
175
184
  this.physicsEngine.destroy();
@@ -786,94 +795,429 @@ export class ExodeUIEngine {
786
795
  const artboardX = ((canvasX - transform.tx) / transform.scaleX) - (this.artboard.width / 2);
787
796
  const artboardY = ((canvasY - transform.ty) / transform.scaleY) - (this.artboard.height / 2);
788
797
 
798
+ this._prevPointerPos = this._lastPointerPos;
799
+ this._lastPointerPos = { x: artboardX, y: artboardY };
800
+
801
+ this.updateInput('mouseX', artboardX);
802
+ this.updateInput('mouseY', artboardY);
803
+
789
804
  this.handlePointerEvent(type, artboardX, artboardY);
790
805
  }
791
806
 
792
807
  private handlePointerEvent(type: string, x: number, y: number) {
793
808
  if (!this.artboard) return;
794
809
 
795
- let hitId: string | null = null;
810
+ let hitObj: any = null;
811
+ console.log(`[ExodeUIEngine] PointerEvent: ${type} at ${x.toFixed(1)},${y.toFixed(1)}`);
812
+
813
+ const objectHandlesEvent = (obj: any, evType: string) => {
814
+ let artboardEvent = evType;
815
+ if (evType === 'click') artboardEvent = 'onClick';
816
+ else if (evType === 'PointerDown') artboardEvent = 'onPointerDown';
817
+ else if (evType === 'PointerUp') artboardEvent = 'onPointerUp';
818
+ else if (evType === 'PointerMove') artboardEvent = 'hover';
819
+
820
+ const aliases = new Set([artboardEvent]);
821
+ if (evType === 'PointerMove') {
822
+ aliases.add('onPointerEnter').add('hover').add('onPointerLeave');
823
+ }
824
+ const interacts = ['button', 'toggle', 'toggle_button', 'dropdown', 'listview', 'inputbox', 'slider'];
825
+ if (obj.type === 'Component' && interacts.includes((obj as any).variant)) return true;
826
+ if (interacts.includes(obj.type?.toLowerCase())) return true;
827
+
828
+ const interactions = obj.interactions || [];
829
+ if (interactions.some((int: any) => aliases.has(int.event))) return true;
830
+
831
+ const triggers = obj.triggers || [];
832
+ if (triggers.some((t: any) => aliases.has(t.eventType))) return true;
833
+
834
+ return false;
835
+ };
836
+
837
+ let topHit: any = null;
796
838
  for (let i = this.artboard.objects.length - 1; i >= 0; i--) {
797
839
  const obj = this.artboard.objects[i];
798
- const isHit = this.hitTest(obj, x, y);
799
-
800
- if (isHit) {
801
- hitId = obj.id;
802
-
803
- // Handle interactions
804
- const interactions = (obj as any).interactions || [];
805
- const matchingInteraction = interactions.find((int: any) => int.event === type || (int.event === 'onClick' && type === 'click'));
806
-
807
- if (matchingInteraction) {
808
- if (matchingInteraction.action === 'setInput') {
809
- this.updateInput(matchingInteraction.targetInputId, matchingInteraction.value);
810
- } else if (matchingInteraction.action === 'fireTrigger') {
811
- this.updateInput(matchingInteraction.targetInputId, true);
812
- }
840
+ const state = this.objectStates.get(obj.id);
841
+ if (state && state.visible !== false && state.opacity > 0 && this.hitTest(obj, x, y)) {
842
+ if (!topHit) topHit = obj;
843
+ if (objectHandlesEvent(obj, type)) {
844
+ hitObj = obj;
845
+ break;
813
846
  }
847
+ }
848
+ }
849
+
850
+ if (!hitObj && topHit) hitObj = topHit;
851
+
852
+ if (hitObj) {
853
+ console.log(`[ExodeUIEngine] Hit object: ${hitObj.name || hitObj.id} (${hitObj.type})`);
854
+ } else {
855
+ console.log(`[ExodeUIEngine] No object hit at ${x.toFixed(1)},${y.toFixed(1)}`);
856
+ }
857
+ const hitId = hitObj?.id || null;
814
858
 
815
- // Handle Component Listeners
816
- if (type === 'click' || type === 'PointerDown') {
817
- const state = this.objectStates.get(obj.id);
818
- const options = state?.options || {};
859
+ if (type === 'PointerMove') {
860
+ if (this.draggingSliderId) {
861
+ const obj = this.artboard.objects.find(o => o.id === this.draggingSliderId);
862
+ if (obj) this.updateSliderValueFromPointer(obj, x, y);
863
+ return;
864
+ }
865
+
866
+ if (this.draggingListViewId) {
867
+ const obj = this.artboard.objects.find(o => o.id === this.draggingListViewId);
868
+ if (obj) this.updateListViewScrollFromPointer(obj, x, y);
869
+ return;
870
+ }
871
+
872
+ if (hitId !== this.lastHoveredObjectId) {
873
+ if (this.lastHoveredObjectId) {
874
+ this.fireEventForObject(this.lastHoveredObjectId, 'onPointerLeave');
875
+ }
876
+ if (hitId) {
877
+ this.fireEventForObject(hitId, 'onPointerEnter');
878
+ }
879
+ this.lastHoveredObjectId = hitId;
880
+ }
881
+ return;
882
+ }
883
+
884
+ if (type === 'PointerUp') {
885
+ this.draggingSliderId = null;
886
+ this.draggingListViewId = null;
887
+ }
888
+
889
+ if (this.activeDropdownId && type === 'PointerDown') {
890
+ const activeObj = this.artboard.objects.find(o => o.id === this.activeDropdownId);
891
+ if (activeObj && (activeObj.type === 'Dropdown' || (activeObj as any).variant === 'dropdown')) {
892
+ const state = this.objectStates.get(activeObj.id);
893
+ if (state) {
894
+ const opts = state.options || {};
895
+ const w = state.width || (activeObj.geometry as any)?.width || 160;
896
+ const h = state.height || (activeObj.geometry as any)?.height || 40;
897
+ const world = this.getWorldTransform(activeObj.id);
819
898
 
820
- if (obj.type === 'Toggle' || (obj as any).variant === 'toggle') {
821
- const newValue = !options.checked;
822
- this.updateObjectOptions(obj.id, { checked: newValue });
823
- if (this.onToggle) this.onToggle(obj.name, newValue);
824
- if (obj.inputId) this.updateInput(obj.inputId, newValue);
825
- } else if ((obj as any).type === 'TextInput' || (obj as any).variant === 'textinput') {
826
- this.focusedId = obj.id;
827
- if (this.onInputFocus) this.onInputFocus(obj.name);
899
+ if (hitId !== this.activeDropdownId) {
900
+ // Check if click is inside the expanded options list
901
+ const optionsList = opts.optionsList || [];
902
+ const itemH = opts.itemHeight || 36;
903
+ const listTop = world.y + h / 2;
904
+ const listBottom = listTop + optionsList.length * itemH;
905
+ const listLeft = world.x - w / 2;
906
+ const listRight = world.x + w / 2;
907
+
908
+ if (x >= listLeft && x <= listRight && y >= listTop && y <= listBottom) {
909
+ // Hit inside the options list — select item
910
+ const itemIndex = Math.floor((y - listTop) / itemH);
911
+ if (itemIndex >= 0 && itemIndex < optionsList.length) {
912
+ const selected = optionsList[itemIndex];
913
+ const selectedValue = typeof selected === 'string' ? selected : (selected?.label || selected?.value || itemIndex);
914
+ opts.activeItemIndex = itemIndex;
915
+ opts.selectedValue = selectedValue;
916
+ opts.isOpen = false;
917
+ this.activeDropdownId = null;
918
+ if (activeObj.inputId) this.updateInput(activeObj.inputId, selectedValue);
919
+ if (this.onComponentChange) {
920
+ this.onComponentChange({ objectId: activeObj.id, componentName: activeObj.name, variant: 'dropdown', property: 'selected', value: selectedValue });
921
+ }
922
+ console.log(`[ExodeUIEngine] Dropdown selected: ${selectedValue}`);
923
+ }
924
+ } else {
925
+ // Tap outside — close
926
+ opts.isOpen = false;
927
+ this.activeDropdownId = null;
928
+ }
929
+ return;
828
930
  }
829
931
  }
932
+ }
933
+ }
934
+
935
+ if (hitObj) {
936
+ let targetObj = hitObj;
937
+ let current: any = hitObj;
938
+ while (current && current.parentId) {
939
+ const parent = this.artboard.objects.find(o => o.id === current.parentId);
940
+ if (parent && parent.type === 'Component') {
941
+ targetObj = parent;
942
+ break;
943
+ }
944
+ current = parent;
945
+ }
830
946
 
831
- // Handle triggers (Animations)
832
- const triggers = obj.triggers || [];
833
- const matchingTrigger = triggers.find(t => t.eventType === type || (t.eventType === 'onClick' && type === 'click'));
834
-
835
- if (matchingTrigger && matchingTrigger.entryAnimationId) {
836
- const anim = this.artboard.animations.find(a => a.id === matchingTrigger.entryAnimationId);
837
- if (anim) {
838
- this.activeTriggers.set(obj.id, {
839
- triggerId: matchingTrigger.id,
840
- animation: anim,
841
- time: 0,
842
- phase: 'entry',
843
- elapsedHold: 0
844
- });
947
+ let artboardEvent = type;
948
+ if (type === 'click') artboardEvent = 'onClick';
949
+ else if (type === 'PointerDown') artboardEvent = 'onPointerDown';
950
+ else if (type === 'PointerUp') artboardEvent = 'onPointerUp';
951
+
952
+ this.fireEventForObject(hitObj.id, artboardEvent);
953
+ if (targetObj !== hitObj) {
954
+ this.fireEventForObject(targetObj.id, artboardEvent);
955
+ }
956
+
957
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'toggle') {
958
+ if (type === 'PointerDown') {
959
+ const state = this.objectStates.get(targetObj.id);
960
+ const opts = state?.options || targetObj.options || {};
961
+ opts.checked = !opts.checked;
962
+ if (targetObj.inputId) this.updateInput(targetObj.inputId, opts.checked);
963
+ if (this.onToggle) this.onToggle(targetObj.name, opts.checked);
964
+ }
965
+ }
966
+
967
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'slider') {
968
+ if (type === 'PointerDown') {
969
+ this.draggingSliderId = targetObj.id;
970
+ this.updateSliderValueFromPointer(targetObj, x, y);
971
+ }
972
+ }
973
+
974
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'dropdown') {
975
+ if (type === 'PointerDown') {
976
+ const state = this.objectStates.get(targetObj.id);
977
+ if (!state) return;
978
+ if (!state.options) state.options = {};
979
+ const isOpening = !state.options.isOpen;
980
+ state.options.isOpen = isOpening;
981
+ this.activeDropdownId = isOpening ? targetObj.id : null;
982
+ console.log(`[ExodeUIEngine] Dropdown toggled: isOpen=${isOpening}`);
983
+ }
984
+ }
985
+
986
+ if (targetObj.type === 'Component' && (targetObj as any).variant === 'listview') {
987
+ if (type === 'PointerDown') {
988
+ this.draggingListViewId = targetObj.id;
989
+ this._prevPointerPos = { x, y };
990
+
991
+ const itemIndex = this.getListViewItemIndexAtPointer(targetObj, x, y);
992
+ if (itemIndex !== null) {
993
+ const state = this.objectStates.get(targetObj.id) || {};
994
+ const opts = state.options || targetObj.options || {};
845
995
 
846
- if (this.onTrigger) {
847
- this.onTrigger(matchingTrigger.name, anim.name);
996
+ opts.activeItemIndex = itemIndex;
997
+ const selected = (opts.items || [])[itemIndex];
998
+ const selectedValue = typeof selected === 'string' ? selected : (selected?.label || selected?.value || itemIndex);
999
+ opts.selectedValue = selectedValue;
1000
+
1001
+ if (targetObj.inputId) {
1002
+ this.updateInput(targetObj.inputId, selectedValue);
1003
+ }
1004
+
1005
+ if (this.onComponentChange) {
1006
+ this.onComponentChange({
1007
+ objectId: targetObj.id,
1008
+ componentName: targetObj.name,
1009
+ variant: 'listview',
1010
+ property: 'activeItemIndex',
1011
+ value: itemIndex
1012
+ });
1013
+ this.onComponentChange({
1014
+ objectId: targetObj.id,
1015
+ componentName: targetObj.name,
1016
+ variant: 'listview',
1017
+ property: 'selected',
1018
+ value: selectedValue
1019
+ });
848
1020
  }
849
- return;
850
1021
  }
851
1022
  }
852
- break;
853
1023
  }
854
1024
  }
1025
+ }
855
1026
 
856
- if (!hitId && (type === 'click' || type === 'PointerDown')) {
857
- if (this.focusedId) {
858
- const focusedObj = this.artboard.objects.find(o => o.id === this.focusedId);
859
- if (focusedObj && this.onInputBlur) this.onInputBlur(focusedObj.name);
860
- this.focusedId = null;
1027
+ private fireEventForObject(objectId: string, eventType: string) {
1028
+ if (!this.artboard) return;
1029
+ const obj = this.artboard.objects.find(o => o.id === objectId);
1030
+ if (!obj) return;
1031
+
1032
+ const eventAliases = new Set([eventType]);
1033
+ if (eventType === 'onPointerEnter') eventAliases.add('hover');
1034
+ if (eventType === 'onPointerLeave') eventAliases.add('blur');
1035
+
1036
+ const interactions = (obj as any).interactions || [];
1037
+ const matchingInteraction = interactions.find((int: any) => eventAliases.has(int.event));
1038
+
1039
+ if (matchingInteraction) {
1040
+ if (matchingInteraction.action === 'setInput') {
1041
+ this.updateInput(matchingInteraction.targetInputId, matchingInteraction.value);
1042
+ } else if (matchingInteraction.action === 'fireTrigger') {
1043
+ this.updateInput(matchingInteraction.targetInputId, true);
1044
+ }
1045
+ }
1046
+
1047
+ const triggers = obj.triggers || [];
1048
+ const matchingTrigger = triggers.find(t => eventAliases.has(t.eventType));
1049
+
1050
+ if (matchingTrigger && matchingTrigger.entryAnimationId) {
1051
+ const anim = this.artboard.animations.find(a => a.id === matchingTrigger.entryAnimationId);
1052
+ if (anim) {
1053
+ this.activeTriggers.set(obj.id, {
1054
+ triggerId: matchingTrigger.id,
1055
+ animation: anim,
1056
+ time: 0,
1057
+ phase: 'entry',
1058
+ elapsedHold: 0
1059
+ });
1060
+ if (this.onTrigger) this.onTrigger(matchingTrigger.name, anim.name);
861
1061
  }
862
1062
  }
863
1063
  }
864
1064
 
1065
+ private updateSliderValueFromPointer(obj: any, x: number, y: number) {
1066
+ const state = this.objectStates.get(obj.id);
1067
+ const world = this.getWorldTransform(obj.id);
1068
+ const opts = state?.options || obj.options || {};
1069
+ const w = obj.geometry?.width || 200;
1070
+
1071
+ const thumbWidth = opts.thumbWidth ?? 16;
1072
+ const travelW = w - thumbWidth;
1073
+
1074
+ const relativeX = (x - world.x) / world.scaleX;
1075
+ const percentage = Math.max(0, Math.min(1, (relativeX + travelW / 2) / travelW));
1076
+
1077
+ const min = opts.min ?? 0;
1078
+ const max = opts.max ?? 100;
1079
+ const newValue = min + (percentage * (max - min));
1080
+
1081
+ this.updateObjectOptions(obj.id, { value: newValue });
1082
+ if (obj.inputId) this.updateInput(obj.inputId, newValue);
1083
+ if (this.onComponentChange) {
1084
+ this.onComponentChange({
1085
+ objectId: obj.id,
1086
+ componentName: obj.name,
1087
+ variant: 'slider',
1088
+ property: 'value',
1089
+ value: newValue
1090
+ });
1091
+ }
1092
+ }
1093
+
1094
+ private updateListViewScrollFromPointer(obj: any, x: number, y: number) {
1095
+ if (!this._prevPointerPos) {
1096
+ this._prevPointerPos = { x, y };
1097
+ return;
1098
+ }
1099
+ const dy = y - this._prevPointerPos.y;
1100
+ const state = this.objectStates.get(obj.id) || {};
1101
+ const opts = state.options || obj.options || {};
1102
+ opts.scrollOffset = (opts.scrollOffset || 0) + dy;
1103
+
1104
+ const items = opts.items || [];
1105
+ const itemGap = opts.itemGap ?? 10;
1106
+ const itemHeight = opts.itemHeight ?? 50;
1107
+ const h = state.height !== undefined ? state.height : (obj.geometry?.height || 300);
1108
+
1109
+ const totalSize = (items.length || 0) * itemHeight + Math.max(0, (items.length || 0) - 1) * itemGap;
1110
+ const maxScroll = Math.max(0, totalSize - h + (opts.padding * 2 || 0));
1111
+
1112
+ if (opts.scrollOffset > 0) opts.scrollOffset = 0;
1113
+ if (opts.scrollOffset < -maxScroll) opts.scrollOffset = -maxScroll;
1114
+
1115
+ this._prevPointerPos = { x, y };
1116
+ }
1117
+
1118
+ private updateListViewHoverFromPointer(obj: any, x: number, y: number) {
1119
+ const index = this.getListViewItemIndexAtPointer(obj, x, y);
1120
+ const state = this.objectStates.get(obj.id) || {};
1121
+ const opts = state.options || obj.options || {};
1122
+ if (opts.hoveredIndex !== index) {
1123
+ opts.hoveredIndex = index;
1124
+ }
1125
+ }
1126
+
1127
+ private getListViewItemIndexAtPointer(obj: any, x: number, y: number): number | null {
1128
+ const state = this.objectStates.get(obj.id) || {};
1129
+ const opts = state.options || obj.options || {};
1130
+ const items = opts.items || [];
1131
+ if (items.length === 0) return null;
1132
+
1133
+ const w = state.width !== undefined ? state.width : (obj.geometry?.width || 200);
1134
+ const h = state.height !== undefined ? state.height : (obj.geometry?.height || 300);
1135
+ const itemGap = opts.itemGap ?? 10;
1136
+ const itemHeight = opts.itemHeight ?? 50;
1137
+ const scrollOffset = opts.scrollOffset ?? 0;
1138
+ let padVal = opts.padding ?? 0;
1139
+ if (typeof padVal !== 'number') padVal = padVal[0] || 0;
1140
+
1141
+ const worldTransform = this.getWorldTransform(obj.id);
1142
+ const dx = x - worldTransform.x;
1143
+ const dy = y - worldTransform.y;
1144
+ const rad = -(worldTransform.rotation) * (Math.PI / 180);
1145
+ const cos = Math.cos(rad);
1146
+ const sin = Math.sin(rad);
1147
+ const localX = dx * cos - dy * sin;
1148
+ const localY = dx * sin + dy * cos;
1149
+
1150
+ const startX = -w / 2;
1151
+ const startY = -h / 2;
1152
+
1153
+ let currentPos = startY + scrollOffset + padVal;
1154
+
1155
+ for (let i = 0; i < items.length; i++) {
1156
+ const iW = w;
1157
+ const iH = itemHeight;
1158
+ const iX = startX;
1159
+ const iY = currentPos;
1160
+
1161
+ if (localX >= iX && localX <= iX + iW && localY >= iY && localY <= iY + iH) {
1162
+ return i;
1163
+ }
1164
+ currentPos += itemHeight + itemGap;
1165
+ }
1166
+
1167
+ return null;
1168
+ }
1169
+
865
1170
  private hitTest(obj: ShapeObject, x: number, y: number): boolean {
866
1171
  const state = this.objectStates.get(obj.id);
867
1172
  if (!state || state.visible === false) return false;
868
1173
 
1174
+ const world = this.getWorldTransform(obj.id);
869
1175
  const w = state.width || (obj.geometry as any).width || 100;
870
1176
  const h = state.height || (obj.geometry as any).height || 100;
871
1177
 
872
- const dx = x - state.x;
873
- const dy = y - state.y;
1178
+ const dx = x - world.x;
1179
+ const dy = y - world.y;
874
1180
 
875
- // Basic AABB check
876
- return Math.abs(dx) <= w / 2 && Math.abs(dy) <= h / 2;
1181
+ // Basic AABB check in world space
1182
+ // Note: Full matrix-based hit test would be better for rotated objects
1183
+ return Math.abs(dx) <= (w * Math.abs(world.scaleX)) / 2 &&
1184
+ Math.abs(dy) <= (h * Math.abs(world.scaleY)) / 2;
1185
+ }
1186
+
1187
+ private getWorldTransform(objId: string): { x: number, y: number, scaleX: number, scaleY: number, rotation: number } {
1188
+ const state = this.objectStates.get(objId);
1189
+ if (!state) return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 };
1190
+
1191
+ const obj = this.artboard?.objects.find(o => o.id === objId);
1192
+ if (!obj || !obj.parentId) {
1193
+ return {
1194
+ x: state.x,
1195
+ y: state.y,
1196
+ scaleX: state.scale_x ?? 1,
1197
+ scaleY: state.scale_y ?? 1,
1198
+ rotation: state.rotation ?? 0
1199
+ };
1200
+ }
1201
+
1202
+ const parentWorld = this.getWorldTransform(obj.parentId);
1203
+
1204
+ const rad = parentWorld.rotation * (Math.PI / 180);
1205
+ const cos = Math.cos(rad);
1206
+ const sin = Math.sin(rad);
1207
+
1208
+ const sx = state.x * parentWorld.scaleX;
1209
+ const sy = state.y * parentWorld.scaleY;
1210
+
1211
+ const worldX = parentWorld.x + (sx * cos - sy * sin);
1212
+ const worldY = parentWorld.y + (sx * sin + sy * cos);
1213
+
1214
+ return {
1215
+ x: worldX,
1216
+ y: worldY,
1217
+ scaleX: (state.scale_x ?? 1) * parentWorld.scaleX,
1218
+ scaleY: (state.scale_y ?? 1) * parentWorld.scaleY,
1219
+ rotation: parentWorld.rotation + (state.rotation ?? 0)
1220
+ };
877
1221
  }
878
1222
 
879
1223
  private applyAnimation(anim: SDKAnimation, time: number) {
@@ -986,15 +1330,26 @@ export class ExodeUIEngine {
986
1330
 
987
1331
  try {
988
1332
  const bg = this.artboard.backgroundColor || '#000000';
989
- canvas.clear(Skia.Color(bg));
1333
+ if (bg === 'transparent' || bg === 'rgba(0,0,0,0)') {
1334
+ canvas.clear(Skia.Color('rgba(0,0,0,0)'));
1335
+ } else {
1336
+ canvas.clear(Skia.Color(bg));
1337
+ }
990
1338
  } catch (e) {
991
- console.error('[ExodeUIEngine] Rendering failed (clear):', e);
992
1339
  canvas.clear(Skia.Color('#000000'));
993
1340
  }
1341
+ this._renderCount++;
1342
+ if (this._renderCount % 60 === 0) {
1343
+ console.log(`[ExodeUIEngine] Rendering frame ${this._renderCount}, objects: ${this.artboard.objects?.length}`);
1344
+ }
994
1345
 
995
1346
  const abWidth = this.artboard.width;
996
1347
  const abHeight = this.artboard.height;
997
1348
 
1349
+ if (this._renderCount % 60 === 1) {
1350
+ console.log(`[ExodeUIEngine] Artboard size: ${abWidth}x${abHeight}, Canvas size: ${width}x${height}`);
1351
+ }
1352
+
998
1353
  // Calculate Layout Transform
999
1354
  const transform = this.calculateTransform(
1000
1355
  width, height,
@@ -1007,16 +1362,22 @@ export class ExodeUIEngine {
1007
1362
  canvas.translate(transform.tx, transform.ty);
1008
1363
  canvas.scale(transform.scaleX, transform.scaleY);
1009
1364
 
1365
+ if (this._renderCount % 60 === 1) {
1366
+ console.log(`[ExodeUIEngine] Viewport Transform: tx=${transform.tx.toFixed(2)}, ty=${transform.ty.toFixed(2)}, scale=${transform.scaleX.toFixed(2)}`);
1367
+ }
1368
+
1010
1369
  // Center the Artboard Origin
1011
1370
  canvas.translate(abWidth / 2, abHeight / 2);
1012
1371
 
1013
1372
  // Clip to artboard bounds
1014
1373
  const clipPath = Skia.Path.Make();
1015
1374
  clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
1016
- canvas.clipPath(clipPath, 1, true);
1375
+ canvas.clipPath(clipPath, 1, true); // 1 = Intersect
1017
1376
 
1018
1377
  this.artboard.objects?.forEach((obj: ShapeObject) => {
1019
- this.renderObject(canvas, obj);
1378
+ if (!obj.parentId) {
1379
+ this.renderObject(canvas, obj);
1380
+ }
1020
1381
  });
1021
1382
 
1022
1383
  canvas.restore();
@@ -1101,15 +1462,13 @@ export class ExodeUIEngine {
1101
1462
  return { scaleX, scaleY, tx, ty };
1102
1463
  }
1103
1464
 
1104
- private renderObject(canvas: SkCanvas, obj: ShapeObject) {
1465
+ private renderObject(canvas: any, obj: ShapeObject) {
1105
1466
  const state = this.objectStates.get(obj.id);
1106
1467
  if (!state || state.visible === false) return;
1107
1468
 
1108
1469
  const geometry = state.geometry || obj.geometry;
1109
-
1110
1470
  const w = state.width || (geometry as any).width || 0;
1111
1471
  const h = state.height || (geometry as any).height || 0;
1112
-
1113
1472
  const cx = state.x || 0;
1114
1473
  const cy = state.y || 0;
1115
1474
  const rotation = state.rotation || 0;
@@ -1118,97 +1477,540 @@ export class ExodeUIEngine {
1118
1477
 
1119
1478
  canvas.save();
1120
1479
  canvas.translate(cx, cy);
1121
- canvas.rotate(rotation, 0, 0);
1480
+ canvas.rotate(rotation * Math.PI / 180, 0, 0);
1122
1481
  canvas.scale(scaleX, scaleY);
1123
1482
 
1124
- const style = state.style || obj.style;
1483
+ if (this._renderCount % 60 === 1) {
1484
+ console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}`);
1485
+ }
1125
1486
 
1126
1487
  if (geometry.type === 'Text') {
1127
- // Simple text rendering placeholder
1488
+ this.renderText(canvas, obj, w, h);
1128
1489
  } else if (geometry.type === 'Image') {
1129
- // Image rendering placeholder
1490
+ this.renderImage(canvas, obj, w, h);
1491
+ } else if (obj.type === 'Component' && (obj as any).variant === 'button') {
1492
+ this.renderButton(canvas, obj, w, h);
1493
+ } else if (obj.type === 'Component' && (obj as any).variant === 'toggle') {
1494
+ this.renderToggle(canvas, obj, w, h);
1495
+ } else if (obj.type === 'Component' && (obj as any).variant === 'slider') {
1496
+ this.renderSlider(canvas, obj, w, h);
1497
+ } else if (obj.type === 'Component' && (obj as any).variant === 'dropdown') {
1498
+ this.renderDropdown(canvas, obj, w, h);
1499
+ } else if (geometry.type === 'LineGraph') {
1500
+ this.renderLineGraph(canvas, geometry, w, h);
1501
+ } else if (obj.type === 'Component' && (obj as any).variant === 'listview') {
1502
+ this.renderListView(canvas, obj, w, h);
1503
+ } else if (obj.type === 'Component' && (obj as any).variant === 'inputbox') {
1504
+ this.renderInputBox(canvas, obj, w, h);
1505
+ } else if (geometry.type === 'SVG') {
1506
+ this.renderSVG(canvas, obj, w, h);
1130
1507
  } else {
1131
- // Shapes
1132
- const path = Skia.Path.Make();
1133
-
1134
- if (geometry.type === 'Rectangle') {
1135
- const rect = { x: -w/2, y: -h/2, width: w, height: h };
1136
- if (state.cornerRadius || geometry.corner_radius) {
1137
- const cr = state.cornerRadius || geometry.corner_radius;
1138
- path.addRRect(Skia.RRectXY(rect, cr, cr));
1139
- } else {
1140
- path.addRect(rect);
1141
- }
1142
- } else if (geometry.type === 'Ellipse') {
1143
- path.addOval({ x: -w/2, y: -h/2, width: w, height: h });
1144
- } else if (geometry.type === 'Triangle') {
1145
- path.moveTo(0, -h/2);
1146
- path.lineTo(w/2, h/2);
1147
- path.lineTo(-w/2, h/2);
1148
- path.close();
1149
- } else if (geometry.type === 'Star') {
1150
- const ir = geometry.inner_radius || 20;
1151
- const or = geometry.outer_radius || 50;
1152
- const sp = geometry.points || 5;
1153
- for (let i = 0; i < sp * 2; i++) {
1154
- const a = (i * Math.PI / sp) - (Math.PI / 2);
1155
- const rad = i % 2 === 0 ? or : ir;
1156
- const px = rad * Math.cos(a);
1157
- const py = rad * Math.sin(a);
1158
- if (i === 0) path.moveTo(px, py);
1159
- else path.lineTo(px, py);
1160
- }
1161
- path.close();
1162
- }
1508
+ // Default to shape rendering
1509
+ this.renderShape(canvas, obj, w, h);
1510
+ }
1511
+
1512
+ // Always check for text property on non-component shapes
1513
+ if (obj.text && (obj.type !== 'Component' || geometry.type === 'Text')) {
1514
+ this.renderText(canvas, obj, w, h);
1515
+ }
1516
+
1517
+ if (obj.children && obj.children.length > 0) {
1518
+ obj.children.forEach(childId => {
1519
+ const childObj = this.artboard?.objects.find(o => o.id === childId);
1520
+ if (childObj) this.renderObject(canvas, childObj);
1521
+ });
1522
+ }
1163
1523
 
1164
- if (style.fill) {
1165
- const paint = Skia.Paint();
1524
+ canvas.restore();
1525
+ }
1526
+
1527
+ private getFont(size: number, family: string = 'System'): SkFont {
1528
+ if (size <= 0) size = 14;
1529
+
1530
+ if (this.fonts.has(size)) return this.fonts.get(size)!;
1531
+
1532
+ try {
1533
+ const fontMgr = Skia.FontMgr.System();
1534
+ let typeface = fontMgr.matchFamilyStyle(family, { weight: 400 });
1535
+
1536
+ if (!typeface) {
1537
+ typeface = fontMgr.matchFamilyStyle("System", { weight: 400 });
1538
+ }
1539
+ if (!typeface) {
1540
+ typeface = fontMgr.matchFamilyStyle("sans-serif", { weight: 400 });
1541
+ }
1542
+ if (!typeface) {
1543
+ typeface = fontMgr.matchFamilyStyle("Arial", { weight: 400 });
1544
+ }
1545
+
1546
+ if (typeface) {
1547
+ const font = Skia.Font(typeface, size);
1548
+ this.fonts.set(size, font);
1549
+ return font;
1550
+ }
1551
+
1552
+ // Fallback to matchFont utility with generic sizing
1553
+ const font = matchFont({ fontSize: size, fontFamily: 'System' });
1554
+ if (font) {
1555
+ this.fonts.set(size, font);
1556
+ return font;
1557
+ }
1558
+
1559
+ // Absolute last resort
1560
+ const defaultFont = Skia.Font(null as any, size);
1561
+ this.fonts.set(size, defaultFont);
1562
+ return defaultFont;
1563
+ } catch (e) {
1564
+ console.error(`[ExodeUIEngine] getFont error: ${e}. Attempting last resort.`);
1166
1565
  try {
1167
- paint.setColor(Skia.Color(style.fill.color || '#000000'));
1168
- } catch (e) {
1169
- paint.setColor(Skia.Color('#FF00FF')); // Debug pink
1566
+ const font = matchFont({ fontSize: size });
1567
+ this.fonts.set(size, font);
1568
+ return font;
1569
+ } catch (e2) {
1570
+ console.error(`[ExodeUIEngine] Total font failure: ${e2}`);
1571
+ return Skia.Font(null as any, size);
1170
1572
  }
1171
- paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
1172
- paint.setStyle(PaintStyle.Fill);
1173
-
1174
- if (style.shadow && style.shadow.opacity > 0) {
1175
- const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
1176
- const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
1177
- paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
1178
- style.shadow.offsetX, style.shadow.offsetY,
1179
- style.shadow.blur, style.shadow.blur,
1180
- Skia.Color(colorWithAlpha)
1181
- ));
1573
+ }
1574
+ }
1575
+
1576
+ private renderText(canvas: any, obj: any, w: number, h: number) {
1577
+ const state = this.objectStates.get(obj.id);
1578
+ const geom = state?.geometry || obj.geometry;
1579
+ const text = obj.text || geom.text || '';
1580
+ if (!text) return;
1581
+
1582
+ const paint = Skia.Paint();
1583
+ const colorValue = obj.style?.fill?.color || '#ffffff';
1584
+ try {
1585
+ const skColor = Skia.Color(colorValue);
1586
+ paint.setColor(skColor);
1587
+ } catch (e) {
1588
+ paint.setColor(Skia.Color('#ffffff'));
1589
+ }
1590
+
1591
+ const opacity1 = state?.opacity !== undefined ? state.opacity : 1;
1592
+ const opacity2 = obj.style?.fill?.opacity !== undefined ? obj.style.fill.opacity : 1;
1593
+ paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
1594
+
1595
+ const fontSize = geom.fontSize || 14;
1596
+ const font = this.getFont(fontSize, geom.fontFamily || obj.fontFamily || 'System');
1597
+
1598
+ const align = geom.textAlign || obj.textAlign || 'center';
1599
+ let x = -w/2;
1600
+
1601
+ // Calculate text width for alignment
1602
+ const textWidth = font.getTextWidth(text);
1603
+ if (align === 'center') {
1604
+ x = -textWidth / 2;
1605
+ } else if (align === 'right') {
1606
+ x = w/2 - textWidth;
1607
+ }
1608
+
1609
+ if (this._renderCount % 60 === 1) {
1610
+ console.log(`[ExodeUIEngine] Drawing text: "${text}" align=${align} width=${textWidth} x=${x} color=${colorValue} alpha=${paint.getAlphaf()} font_size=${font.getSize()}`);
1611
+ }
1612
+
1613
+ // Check baseline (web typically aligns center of text roughly to center if no baseline is provided, but standard is y=fontSize/2 or similar)
1614
+ const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 3 : fontSize / 2;
1615
+
1616
+ canvas.drawText(text, x, y, paint, font);
1617
+ }
1618
+
1619
+ private renderImage(canvas: any, obj: any, w: number, h: number) {
1620
+ // Image support requires Skia.Image.MakeFromExternalSource or similar
1621
+ // Placeholder for now
1622
+ const paint = Skia.Paint();
1623
+ paint.setColor(Skia.Color('#374151'));
1624
+ canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
1625
+ }
1626
+
1627
+ private renderButton(canvas: any, obj: any, w: number, h: number) {
1628
+ const state = this.objectStates.get(obj.id);
1629
+ const opts = state?.options || obj.options || {};
1630
+ const paint = Skia.Paint();
1631
+
1632
+ const isPressed = this.draggingSliderId === obj.id; // Reuse logic for now
1633
+ paint.setColor(Skia.Color(isPressed ? (opts.activeBackgroundColor || '#2563eb') : (opts.backgroundColor || '#3b82f6')));
1634
+
1635
+ const r = opts.cornerRadius ?? 8;
1636
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1637
+
1638
+ const label = opts.label || { text: opts.text || 'Button', fontSize: 14, color: '#ffffff' };
1639
+ if (label && label.text) {
1640
+ const textPaint = Skia.Paint();
1641
+ textPaint.setColor(Skia.Color(label.color || '#ffffff'));
1642
+ const fontSize = label.fontSize || 14;
1643
+ const font = this.getFont(fontSize);
1644
+
1645
+ // DEBUG: Cyan marker for button text
1646
+ const linePaint = Skia.Paint();
1647
+ linePaint.setColor(Skia.Color('cyan'));
1648
+ linePaint.setStrokeWidth(1);
1649
+ canvas.drawLine(-w/4, fontSize/2, w/4, fontSize/2, linePaint);
1650
+
1651
+ if (this._renderCount % 60 === 1) {
1652
+ console.log(`[ExodeUIEngine] Button text: "${label.text}" at ${-w/4},${fontSize/2} font_size=${font.getSize()}`);
1182
1653
  }
1183
- if (style.blur && style.blur.amount > 0) {
1184
- paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
1654
+
1655
+ canvas.drawText(label.text, -w/4, fontSize / 2, textPaint, font);
1656
+ }
1657
+ }
1658
+
1659
+ private renderToggle(canvas: any, obj: any, w: number, h: number) {
1660
+ const state = this.objectStates.get(obj.id);
1661
+ const opts = state?.options || obj.options || {};
1662
+ const checked = opts.checked || false;
1663
+
1664
+ const paint = Skia.Paint();
1665
+ paint.setColor(Skia.Color(checked ? (opts.activeColor || '#3b82f6') : (opts.inactiveColor || '#374151')));
1666
+
1667
+ const r = h / 2;
1668
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1669
+
1670
+ const thumbPaint = Skia.Paint();
1671
+ thumbPaint.setColor(Skia.Color('#ffffff'));
1672
+ const thumbRadius = (h - 8) / 2;
1673
+ const thumbX = checked ? (w / 2 - thumbRadius - 4) : (-w / 2 + thumbRadius + 4);
1674
+ canvas.drawCircle(thumbX, 0, thumbRadius, thumbPaint);
1675
+ }
1676
+
1677
+ private renderSlider(canvas: any, obj: any, w: number, h: number) {
1678
+ const state = this.objectStates.get(obj.id);
1679
+ const opts = state?.options || obj.options || {};
1680
+ const value = opts.value ?? 50;
1681
+ const min = opts.min ?? 0;
1682
+ const max = opts.max ?? 100;
1683
+ const percentage = (value - min) / (max - min);
1684
+
1685
+ const trackPaint = Skia.Paint();
1686
+ trackPaint.setColor(Skia.Color(opts.inactiveColor || '#374151'));
1687
+ const trackHeight = opts.trackHeight || 4;
1688
+ canvas.drawRect({ x: -w/2, y: -trackHeight/2, width: w, height: trackHeight }, trackPaint);
1689
+
1690
+ const activePaint = Skia.Paint();
1691
+ activePaint.setColor(Skia.Color(opts.activeColor || '#3b82f6'));
1692
+ const thumbWidth = opts.thumbWidth ?? 16;
1693
+ const travelW = w - thumbWidth;
1694
+ const thumbX = -w / 2 + (thumbWidth / 2) + (percentage * travelW);
1695
+ canvas.drawRect({ x: -w/2, y: -trackHeight/2, width: thumbX + w/2, height: trackHeight }, activePaint);
1696
+
1697
+ const thumbPaint = Skia.Paint();
1698
+ thumbPaint.setColor(Skia.Color(opts.thumbColor || '#ffffff'));
1699
+ canvas.drawCircle(thumbX, 0, thumbWidth / 2, thumbPaint);
1700
+ }
1701
+
1702
+ private renderDropdown(canvas: any, obj: any, w: number, h: number) {
1703
+ const state = this.objectStates.get(obj.id);
1704
+ const opts = state?.options || obj.options || {};
1705
+ const isOpen = opts.isOpen ?? false;
1706
+
1707
+ const paint = Skia.Paint();
1708
+ paint.setColor(Skia.Color(opts.backgroundColor || '#ffffff'));
1709
+ const r = opts.cornerRadius ?? 6;
1710
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1711
+
1712
+ const displayText = opts.selectedValue || opts.placeholder || 'Select...';
1713
+ const textPaint = Skia.Paint();
1714
+ textPaint.setColor(Skia.Color(opts.color || '#333333'));
1715
+ const fontSize = opts.fontSize || 14;
1716
+ const font = this.getFont(fontSize, 'Helvetica Neue');
1717
+
1718
+ canvas.save();
1719
+ canvas.translate(-w/2 + 10, fontSize / 2);
1720
+ canvas.drawText(String(displayText), 0, 0, textPaint, font);
1721
+ canvas.restore();
1722
+
1723
+ // Chevron
1724
+ const chevronPaint = Skia.Paint();
1725
+ chevronPaint.setColor(Skia.Color(opts.chevronColor || '#9ca3af'));
1726
+ chevronPaint.setStyle(PaintStyle.Stroke);
1727
+ chevronPaint.setStrokeWidth(2);
1728
+ const cx = w/2 - 15;
1729
+ const cy = isOpen ? 2 : 0;
1730
+ if (isOpen) {
1731
+ canvas.drawLine(cx - 4, cy + 2, cx, cy - 2, chevronPaint);
1732
+ canvas.drawLine(cx, cy - 2, cx + 4, cy + 2, chevronPaint);
1733
+ } else {
1734
+ canvas.drawLine(cx - 4, cy - 2, cx, cy + 2, chevronPaint);
1735
+ canvas.drawLine(cx, cy + 2, cx + 4, cy - 2, chevronPaint);
1736
+ }
1737
+
1738
+ if (isOpen && opts.optionsList && opts.optionsList.length > 0) {
1739
+ const dropList = opts.optionsList;
1740
+ const dropdownItemH = opts.itemHeight || 36;
1741
+ const dropdownY = h/2;
1742
+ const dropX = -w/2;
1743
+ const totalH = dropList.length * dropdownItemH;
1744
+
1745
+ const dropBgPaint = Skia.Paint();
1746
+ dropBgPaint.setColor(Skia.Color(opts.listBackgroundColor || '#ffffff'));
1747
+ canvas.drawRRect(Skia.RRectXY({ x: dropX, y: dropdownY, width: w, height: totalH }, 6, 6), dropBgPaint);
1748
+
1749
+ const borderP = Skia.Paint();
1750
+ borderP.setColor(Skia.Color(opts.listBorderColor || '#e5e7eb'));
1751
+ borderP.setStyle(PaintStyle.Stroke);
1752
+ borderP.setStrokeWidth(1);
1753
+ canvas.drawRRect(Skia.RRectXY({ x: dropX, y: dropdownY, width: w, height: totalH }, 6, 6), borderP);
1754
+
1755
+ dropList.forEach((item: any, i: number) => {
1756
+ const itemY = dropdownY + i * dropdownItemH;
1757
+ const isActive = opts.activeItemIndex === i;
1758
+ if (isActive) {
1759
+ const activePaint = Skia.Paint();
1760
+ activePaint.setColor(Skia.Color(opts.listHoverColor || '#eff6ff'));
1761
+ canvas.drawRect({ x: dropX, y: itemY, width: w, height: dropdownItemH }, activePaint);
1762
+ }
1763
+ const itemText = typeof item === 'string' ? item : (item?.label || `Option ${i + 1}`);
1764
+ const itemTextP = Skia.Paint();
1765
+ itemTextP.setColor(Skia.Color(opts.listTextColor || (isActive ? '#2563eb' : '#374151')));
1766
+ canvas.save();
1767
+ canvas.translate(dropX + 12, itemY + dropdownItemH / 2);
1768
+ canvas.drawText(String(itemText), 0, fontSize / 3, itemTextP, font);
1769
+ canvas.restore();
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ private renderListView(canvas: any, obj: any, w: number, h: number) {
1775
+ const state = this.objectStates.get(obj.id);
1776
+ const opts = state?.options || obj.options || {};
1777
+ const items = opts.items || [];
1778
+ const scrollOffset = opts.scrollOffset || 0;
1779
+
1780
+ const glassEffect = opts.glassEffect !== false;
1781
+ const cornerR = opts.cornerRadius ?? 12;
1782
+ let listBgColor = opts.listBackgroundColor || opts.backgroundColor || (glassEffect ? 'rgba(31,41,55,0.4)' : '#1f2937');
1783
+ if (listBgColor === 'transparent') listBgColor = 'rgba(0,0,0,0)';
1784
+
1785
+ const getSafeColor = (col: any, fallback: string = '#000000') => {
1786
+ if (!col) return Skia.Color('rgba(0,0,0,0)');
1787
+ if (col === 'transparent') return Skia.Color('rgba(0,0,0,0)');
1788
+
1789
+ let cleanStr = col;
1790
+ if (typeof col === 'object') {
1791
+ cleanStr = col?.color || col?.stops?.[0]?.color || fallback;
1185
1792
  }
1793
+ if (typeof cleanStr !== 'string') return Skia.Color(fallback);
1186
1794
 
1187
- canvas.drawPath(path, paint);
1188
- }
1189
- if (style.stroke) {
1190
- const paint = Skia.Paint();
1191
- paint.setColor(Skia.Color(style.stroke.color));
1192
- paint.setStrokeWidth(style.stroke.width);
1193
- paint.setAlphaf((state.opacity ?? 1) * (style.stroke.opacity ?? 1));
1194
- paint.setStyle(PaintStyle.Stroke);
1195
-
1196
- if (style.shadow && style.shadow.opacity > 0) {
1197
- const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
1198
- const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
1199
- paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
1200
- style.shadow.offsetX, style.shadow.offsetY,
1201
- style.shadow.blur, style.shadow.blur,
1202
- Skia.Color(colorWithAlpha)
1203
- ));
1795
+ const clean = cleanStr.replace(/\s+/g, '');
1796
+ try {
1797
+ return Skia.Color(clean) || Skia.Color(fallback);
1798
+ } catch {
1799
+ return Skia.Color(fallback);
1204
1800
  }
1205
- if (style.blur && style.blur.amount > 0) {
1206
- paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
1801
+ };
1802
+
1803
+ const bgPaint = Skia.Paint();
1804
+ bgPaint.setColor(getSafeColor(listBgColor, '#1f2937'));
1805
+ bgPaint.setStyle(PaintStyle.Fill);
1806
+
1807
+ const rRect = Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, cornerR, cornerR);
1808
+ canvas.drawRRect(rRect, bgPaint);
1809
+
1810
+ if (glassEffect) {
1811
+ const borderPaint = Skia.Paint();
1812
+ borderPaint.setColor(getSafeColor('rgba(255,255,255,0.25)'));
1813
+ borderPaint.setStyle(PaintStyle.Stroke);
1814
+ borderPaint.setStrokeWidth(1);
1815
+ canvas.drawRRect(rRect, borderPaint);
1816
+ }
1817
+
1818
+ canvas.save();
1819
+ // const clipOp = ClipOp ? ClipOp.Intersect : 1;
1820
+ // canvas.clipRRect(rRect, clipOp, true);
1821
+
1822
+ const itemHeight = opts.itemHeight || 50;
1823
+ const itemGap = opts.itemGap ?? 10;
1824
+ const padding = opts.padding ?? 0;
1825
+ const pad = typeof padding === 'number' ? padding : (padding as any)[0] || 0;
1826
+
1827
+ let currentPos = -h/2 + scrollOffset + pad;
1828
+
1829
+ const font = this.getFont(14, 'Helvetica Neue');
1830
+ const accentColor = opts.accentColor || '#3b82f6';
1831
+ const itemTextColor = opts.itemTextColor || '#333333';
1832
+ const itemBgColor = opts.itemBackgroundColor || 'transparent';
1833
+
1834
+ if (this._renderCount <= 2) {
1835
+ // debug logging disabled
1836
+ }
1837
+
1838
+ items.forEach((item: any, i: number) => {
1839
+ const itemY = currentPos;
1840
+ const itemH = itemHeight;
1841
+ const itemW = w;
1842
+
1843
+ if (itemY + itemH > -h/2 && itemY < h/2) {
1844
+ const isHovered = opts.hoveredIndex === i;
1845
+ const isActive = opts.activeItemIndex === i;
1846
+
1847
+ if (isActive) {
1848
+ const activePaint = Skia.Paint();
1849
+ activePaint.setColor(getSafeColor(accentColor, '#3b82f6'));
1850
+ activePaint.setAlphaf(0.8);
1851
+ canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, activePaint);
1852
+ } else if (isHovered) {
1853
+ const hoverPaint = Skia.Paint();
1854
+ hoverPaint.setColor(getSafeColor(glassEffect ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'));
1855
+ canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, hoverPaint);
1856
+ } else if (itemBgColor && itemBgColor !== 'transparent') {
1857
+ const itemBgPaint = Skia.Paint();
1858
+ itemBgPaint.setColor(getSafeColor(itemBgColor));
1859
+ canvas.drawRect({ x: -w/2, y: itemY, width: itemW, height: itemH }, itemBgPaint);
1860
+ }
1861
+
1862
+ if (isActive || isHovered) {
1863
+ const stripePaint = Skia.Paint();
1864
+ stripePaint.setColor(getSafeColor(accentColor, '#3b82f6'));
1865
+ canvas.drawRect({ x: -w/2, y: itemY, width: 3, height: itemH }, stripePaint);
1866
+ }
1867
+
1868
+ const textPaint = Skia.Paint();
1869
+ if (isActive) {
1870
+ textPaint.setColor(getSafeColor('#ffffff'));
1871
+ } else if (glassEffect) {
1872
+ textPaint.setColor(getSafeColor('#ffffff'));
1873
+ textPaint.setAlphaf(0.9);
1874
+ } else {
1875
+ textPaint.setColor(getSafeColor(itemTextColor, '#ffffff'));
1876
+ }
1877
+
1878
+ const text = typeof item === 'string' ? item : (item?.label || item?.value || `Item ${i + 1}`);
1879
+ canvas.save();
1880
+ canvas.translate(-w/2 + 16, itemY + itemH / 2);
1881
+ canvas.drawText(String(text), 0, 14 / 3, textPaint, font);
1882
+ canvas.restore();
1207
1883
  }
1884
+ currentPos += itemHeight + itemGap;
1885
+ });
1886
+
1887
+ canvas.restore();
1888
+ }
1208
1889
 
1890
+ private renderInputBox(canvas: any, obj: any, w: number, h: number) {
1891
+ const state = this.objectStates.get(obj.id);
1892
+ const opts = state?.options || obj.options || {};
1893
+
1894
+ const paint = Skia.Paint();
1895
+ paint.setColor(Skia.Color(opts.backgroundColor || '#1f2937'));
1896
+ const r = opts.cornerRadius ?? 8;
1897
+ canvas.drawRRect(Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r), paint);
1898
+
1899
+ const textPaint = Skia.Paint();
1900
+ textPaint.setColor(Skia.Color(opts.color || '#ffffff'));
1901
+ const fontSize = opts.fontSize || 14;
1902
+ const font = this.getFont(fontSize);
1903
+ const text = opts.text || '';
1904
+ const display = text || opts.placeholder || 'Enter text...';
1905
+ if (!text) textPaint.setAlphaf(0.5);
1906
+
1907
+ canvas.drawText(display, -w/2 + 15, 5, textPaint, font);
1908
+ }
1909
+
1910
+ private renderSVG(canvas: any, obj: any, w: number, h: number) {
1911
+ const geometry = obj.geometry;
1912
+ const svgContent = geometry.svgContent;
1913
+ if (!svgContent) return;
1914
+
1915
+ // In a real implementation, we'd parse the SVG or use a pre-parsed Skia Path
1916
+ // For now, placeholder or basic path if it's a simple icon
1917
+ const paint = Skia.Paint();
1918
+ paint.setColor(Skia.Color('#ffffff'));
1919
+ paint.setAlphaf(0.8);
1920
+ canvas.drawCircle(0, 0, Math.min(w, h) / 4, paint);
1921
+ }
1922
+
1923
+ private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
1924
+ const state = this.objectStates.get(geom.id);
1925
+ const datasets = geom.datasets || [];
1926
+ if (datasets.length === 0) return;
1927
+
1928
+ const paint = Skia.Paint();
1929
+ paint.setStyle(PaintStyle.Stroke);
1930
+ paint.setStrokeWidth(geom.lineWidth || 2);
1931
+
1932
+ datasets.forEach((ds: any) => {
1933
+ const data = ds.data || [];
1934
+ if (data.length < 2) return;
1935
+
1936
+ paint.setColor(Skia.Color(ds.lineColor || '#3b82f6'));
1937
+ const path = Skia.Path.Make();
1938
+
1939
+ const stepX = w / (data.length - 1);
1940
+ const max = Math.max(...data, 1);
1941
+
1942
+ data.forEach((val: number, i: number) => {
1943
+ const x = -w/2 + i * stepX;
1944
+ const y = h/2 - (val / max) * h;
1945
+ if (i === 0) path.moveTo(x, y);
1946
+ else path.lineTo(x, y);
1947
+ });
1209
1948
  canvas.drawPath(path, paint);
1949
+ });
1950
+ }
1951
+ private renderShape(canvas: SkCanvas, obj: ShapeObject, w: number, h: number) {
1952
+ const state = this.objectStates.get(obj.id);
1953
+ const geometry = state.geometry || obj.geometry;
1954
+ const style = state.style || obj.style;
1955
+
1956
+ const path = Skia.Path.Make();
1957
+ if (geometry.type === 'Rectangle') {
1958
+ const rect = { x: -w/2, y: -h/2, width: w, height: h };
1959
+ if (state.cornerRadius || geometry.corner_radius) {
1960
+ const cr = state.cornerRadius || geometry.corner_radius;
1961
+ path.addRRect(Skia.RRectXY(rect, cr, cr));
1962
+ } else {
1963
+ path.addRect(rect);
1210
1964
  }
1965
+ } else if (geometry.type === 'Ellipse') {
1966
+ path.addOval({ x: -w/2, y: -h/2, width: w, height: h });
1967
+ } else if (geometry.type === 'Triangle') {
1968
+ path.moveTo(0, -h/2);
1969
+ path.lineTo(w/2, h/2);
1970
+ path.lineTo(-w/2, h/2);
1971
+ path.close();
1972
+ } else if (geometry.type === 'Star') {
1973
+ const ir = geometry.inner_radius || 20;
1974
+ const or = geometry.outer_radius || 50;
1975
+ const sp = geometry.points || 5;
1976
+ for (let i = 0; i < sp * 2; i++) {
1977
+ const a = (i * Math.PI / sp) - (Math.PI / 2);
1978
+ const rad = i % 2 === 0 ? or : ir;
1979
+ const px = rad * Math.cos(a);
1980
+ const py = rad * Math.sin(a);
1981
+ if (i === 0) path.moveTo(px, py);
1982
+ else path.lineTo(px, py);
1983
+ }
1984
+ path.close();
1985
+ }
1986
+
1987
+ if (style.fill) {
1988
+ const paint = Skia.Paint();
1989
+ paint.setColor(Skia.Color(style.fill.color || '#000000'));
1990
+ paint.setAlphaf((state.opacity ?? 1) * (style.fill.opacity ?? 1));
1991
+ paint.setStyle(PaintStyle.Fill);
1992
+
1993
+ if (style.shadow && style.shadow.opacity > 0) {
1994
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
1995
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
1996
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
1997
+ style.shadow.offsetX, style.shadow.offsetY,
1998
+ style.shadow.blur, style.shadow.blur,
1999
+ Skia.Color(colorWithAlpha)
2000
+ ));
2001
+ }
2002
+ if (style.blur && style.blur.amount > 0) {
2003
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
2004
+ }
2005
+ canvas.drawPath(path, paint);
2006
+ }
2007
+ if (style.stroke) {
2008
+ const paint = Skia.Paint();
2009
+ paint.setColor(Skia.Color(style.stroke.color));
2010
+ paint.setStrokeWidth(style.stroke.width);
2011
+ paint.setAlphaf((state.opacity ?? 1) * (style.stroke.opacity ?? 1));
2012
+ paint.setStyle(PaintStyle.Stroke);
2013
+ canvas.drawPath(path, paint);
1211
2014
  }
1212
- canvas.restore();
1213
2015
  }
1214
2016
  }
package/src/types.ts CHANGED
@@ -195,6 +195,7 @@ export interface ShapeObject {
195
195
  compositeOperation?: string;
196
196
  options?: any;
197
197
  inputId?: string;
198
+ text?: string;
198
199
  }
199
200
 
200
201
  export interface Keyframe {