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 +1 -1
- package/src/ExodeUIView.tsx +39 -27
- package/src/engine.ts +939 -137
- package/src/types.ts +1 -0
package/package.json
CHANGED
package/src/ExodeUIView.tsx
CHANGED
|
@@ -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]
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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 (
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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 -
|
|
873
|
-
const dy = y -
|
|
1178
|
+
const dx = x - world.x;
|
|
1179
|
+
const dy = y - world.y;
|
|
874
1180
|
|
|
875
|
-
// Basic AABB check
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1488
|
+
this.renderText(canvas, obj, w, h);
|
|
1128
1489
|
} else if (geometry.type === 'Image') {
|
|
1129
|
-
|
|
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
|
-
//
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1145
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
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
|
}
|