@umituz/react-native-video-editor 1.1.63 → 1.1.65
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/application/services/EditorService.ts +151 -0
- package/src/application/usecases/LayerUseCases.ts +192 -0
- package/src/infrastructure/repositories/LayerRepositoryImpl.ts +158 -0
- package/src/infrastructure/services/image-layer-operations.service.ts +1 -1
- package/src/infrastructure/services/shape-layer-operations.service.ts +1 -1
- package/src/infrastructure/services/text-layer-operations.service.ts +1 -1
- package/src/infrastructure/utils/debounce.utils.ts +69 -0
- package/src/infrastructure/utils/image-processing.utils.ts +73 -0
- package/src/infrastructure/utils/position-calculations.utils.ts +45 -161
- package/src/presentation/components/EditorTimeline.tsx +29 -11
- package/src/presentation/components/EditorToolPanel.tsx +71 -172
- package/src/presentation/components/LayerActionsMenu.tsx +97 -159
- package/src/presentation/components/SceneActionsMenu.tsx +34 -44
- package/src/presentation/components/SubtitleListPanel.tsx +55 -28
- package/src/presentation/components/SubtitleStylePicker.tsx +36 -156
- package/src/presentation/components/collage/CollageCanvas.tsx +2 -2
- package/src/presentation/components/collage/CollageLayoutSelector.tsx +0 -4
- package/src/presentation/components/draggable-layer/LayerContent.tsx +7 -2
- package/src/presentation/components/generic/ActionMenu.tsx +110 -0
- package/src/presentation/components/generic/Editor.tsx +65 -0
- package/src/presentation/components/generic/Selector.tsx +96 -0
- package/src/presentation/components/generic/Toolbar.tsx +77 -0
- package/src/presentation/components/image-layer/ImagePreview.tsx +7 -1
- package/src/presentation/components/shape-layer/ColorPickerHorizontal.tsx +20 -55
- package/src/presentation/components/subtitle/SubtitleListItem.tsx +4 -5
- package/src/presentation/components/subtitle/SubtitleModal.tsx +2 -2
- package/src/presentation/components/subtitle/useSubtitleForm.ts +1 -1
- package/src/presentation/components/text-layer/ColorPicker.tsx +21 -55
- package/src/presentation/components/text-layer/FontSizeSelector.tsx +19 -50
- package/src/presentation/components/text-layer/OptionSelector.tsx +18 -55
- package/src/presentation/components/text-layer/TextAlignSelector.tsx +24 -51
- package/src/presentation/hooks/generic/use-layer-form.hook.ts +19 -16
- package/src/presentation/hooks/generic/useForm.ts +99 -0
- package/src/presentation/hooks/generic/useList.ts +117 -0
- package/src/presentation/hooks/useDraggableLayerGestures.ts +28 -25
- package/src/presentation/hooks/useEditorPlayback.ts +19 -2
- package/src/presentation/hooks/useImageLayerForm.ts +9 -6
- package/src/presentation/hooks/useMenuActions.tsx +19 -4
- package/src/presentation/hooks/useTextLayerForm.ts +9 -6
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Form Hook
|
|
3
|
+
* Replaces: useImageLayerForm, useTextLayerForm, useShapeLayerForm, useAudioLayerForm, useAnimationLayerForm
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, useMemo } from "react";
|
|
7
|
+
|
|
8
|
+
export interface FormValidator<T = unknown> {
|
|
9
|
+
required?: boolean;
|
|
10
|
+
validate?: (value: T) => string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface FormConfig<T extends Record<string, unknown>> {
|
|
14
|
+
initialValues: T;
|
|
15
|
+
validators?: Partial<Record<keyof T, FormValidator>>;
|
|
16
|
+
onSubmit: (values: T) => void | Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FormReturn<T extends Record<string, unknown>> {
|
|
20
|
+
values: T;
|
|
21
|
+
errors: Partial<Record<keyof T, string>>;
|
|
22
|
+
touched: Partial<Record<keyof T, boolean>>;
|
|
23
|
+
isValid: boolean;
|
|
24
|
+
isSubmitting: boolean;
|
|
25
|
+
setValue: <K extends keyof T>(field: K, value: T[K]) => void;
|
|
26
|
+
setError: <K extends keyof T>(field: K, error: string | undefined) => void;
|
|
27
|
+
setTouched: <K extends keyof T>(field: K, touched: boolean) => void;
|
|
28
|
+
handleSubmit: () => Promise<void>;
|
|
29
|
+
resetForm: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useForm<T extends Record<string, unknown>>({ initialValues, validators = {}, onSubmit }: FormConfig<T>): FormReturn<T> {
|
|
33
|
+
const [values, setValues] = useState<T>(initialValues);
|
|
34
|
+
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
|
35
|
+
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
|
|
36
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
37
|
+
|
|
38
|
+
const validateField = useCallback(<K extends keyof T>(field: K, value: T[K]): string | undefined => {
|
|
39
|
+
const validator = validators[field];
|
|
40
|
+
if (!validator) return undefined;
|
|
41
|
+
if (validator.required && (value === undefined || value === null || value === "")) return "This field is required";
|
|
42
|
+
if (validator.validate) return validator.validate(value);
|
|
43
|
+
return undefined;
|
|
44
|
+
}, [validators]);
|
|
45
|
+
|
|
46
|
+
const validateForm = useCallback((): boolean => {
|
|
47
|
+
const newErrors: Partial<Record<keyof T, string>> = {};
|
|
48
|
+
let isValid = true;
|
|
49
|
+
(Object.keys(validators) as Array<keyof T>).forEach((field) => {
|
|
50
|
+
const error = validateField(field, values[field]);
|
|
51
|
+
if (error) { newErrors[field] = error; isValid = false; }
|
|
52
|
+
});
|
|
53
|
+
setErrors(newErrors);
|
|
54
|
+
return isValid;
|
|
55
|
+
}, [validators, values, validateField]);
|
|
56
|
+
|
|
57
|
+
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
|
58
|
+
setValues((prev) => ({ ...prev, [field]: value }));
|
|
59
|
+
if (touched[field]) {
|
|
60
|
+
const error = validateField(field, value);
|
|
61
|
+
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
62
|
+
}
|
|
63
|
+
}, [touched, validateField]);
|
|
64
|
+
|
|
65
|
+
const setError = useCallback(<K extends keyof T>(field: K, error: string | undefined) => {
|
|
66
|
+
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const setFieldTouched = useCallback(<K extends keyof T>(field: K, touchedValue: boolean) => {
|
|
70
|
+
setTouched((prev) => ({ ...prev, [field]: touchedValue }));
|
|
71
|
+
if (touchedValue) {
|
|
72
|
+
const error = validateField(field, values[field]);
|
|
73
|
+
setErrors((prev) => ({ ...prev, [field]: error }));
|
|
74
|
+
}
|
|
75
|
+
}, [values, validateField]);
|
|
76
|
+
|
|
77
|
+
const isValid = useMemo(() => Object.keys(validators).every((field) => !errors[field as keyof T]), [errors, validators]);
|
|
78
|
+
|
|
79
|
+
const handleSubmit = useCallback(async () => {
|
|
80
|
+
const allTouched = Object.keys(validators).reduce((acc, field) => ({ ...acc, [field]: true }), {} as Partial<Record<keyof T, boolean>>);
|
|
81
|
+
setTouched(allTouched);
|
|
82
|
+
const valid = validateForm();
|
|
83
|
+
if (!valid) return;
|
|
84
|
+
setIsSubmitting(true);
|
|
85
|
+
try {
|
|
86
|
+
await onSubmit(values);
|
|
87
|
+
} finally {
|
|
88
|
+
setIsSubmitting(false);
|
|
89
|
+
}
|
|
90
|
+
}, [values, validators, onSubmit, validateForm]);
|
|
91
|
+
|
|
92
|
+
const resetForm = useCallback(() => {
|
|
93
|
+
setValues(initialValues);
|
|
94
|
+
setErrors({});
|
|
95
|
+
setTouched({});
|
|
96
|
+
}, [initialValues]);
|
|
97
|
+
|
|
98
|
+
return { values, errors, touched, isValid, isSubmitting, setValue, setError, setTouched: setFieldTouched, handleSubmit, resetForm };
|
|
99
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic List Hook
|
|
3
|
+
* Replaces: SubtitleListPanel, LayerList, SceneList patterns
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, useMemo } from "react";
|
|
7
|
+
|
|
8
|
+
export interface ListConfig<T> {
|
|
9
|
+
items: T[];
|
|
10
|
+
keyExtractor: (item: T) => string;
|
|
11
|
+
initialSelection?: string[];
|
|
12
|
+
multiSelect?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ListReturn<T> {
|
|
16
|
+
selectedIds: Set<string>;
|
|
17
|
+
selectedItems: T[];
|
|
18
|
+
isSelected: (id: string) => boolean;
|
|
19
|
+
toggleSelection: (id: string) => void;
|
|
20
|
+
selectAll: () => void;
|
|
21
|
+
clearSelection: () => void;
|
|
22
|
+
filteredItems: T[];
|
|
23
|
+
filter: (predicate: (item: T) => boolean) => void;
|
|
24
|
+
clearFilter: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useList<T>({ items, keyExtractor, initialSelection = [], multiSelect = false }: ListConfig<T>): ListReturn<T> {
|
|
28
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(initialSelection));
|
|
29
|
+
const [filterPredicate, setFilterPredicate] = useState<((item: T) => boolean) | null>(null);
|
|
30
|
+
|
|
31
|
+
const selectedItems = useMemo(() => items.filter((item) => selectedIds.has(keyExtractor(item))), [items, selectedIds, keyExtractor]);
|
|
32
|
+
const filteredItems = useMemo(() => filterPredicate ? items.filter(filterPredicate) : items, [items, filterPredicate]);
|
|
33
|
+
|
|
34
|
+
const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
|
|
35
|
+
|
|
36
|
+
const toggleSelection = useCallback((id: string) => {
|
|
37
|
+
setSelectedIds((prev) => {
|
|
38
|
+
const newSet = new Set(prev);
|
|
39
|
+
if (multiSelect) {
|
|
40
|
+
if (newSet.has(id)) {
|
|
41
|
+
newSet.delete(id);
|
|
42
|
+
} else {
|
|
43
|
+
newSet.add(id);
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
newSet.clear();
|
|
47
|
+
newSet.add(id);
|
|
48
|
+
}
|
|
49
|
+
return newSet;
|
|
50
|
+
});
|
|
51
|
+
}, [multiSelect]);
|
|
52
|
+
|
|
53
|
+
const selectAll = useCallback(() => setSelectedIds(new Set(items.map(keyExtractor))), [items, keyExtractor]);
|
|
54
|
+
const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
|
|
55
|
+
const filter = useCallback((predicate: (item: T) => boolean) => setFilterPredicate(() => predicate), []);
|
|
56
|
+
const clearFilter = useCallback(() => setFilterPredicate(null), []);
|
|
57
|
+
|
|
58
|
+
return { selectedIds, selectedItems, isSelected, toggleSelection, selectAll, clearSelection, filteredItems, filter, clearFilter };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UseSelectionConfig {
|
|
62
|
+
multiSelect?: boolean;
|
|
63
|
+
maxSelections?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UseSelectionReturn {
|
|
67
|
+
selectedIds: Set<string>;
|
|
68
|
+
isSelected: (id: string) => boolean;
|
|
69
|
+
toggleSelection: (id: string) => void;
|
|
70
|
+
select: (id: string) => void;
|
|
71
|
+
deselect: (id: string) => void;
|
|
72
|
+
clearSelection: () => void;
|
|
73
|
+
selectionCount: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useSelection({ multiSelect = false, maxSelections }: UseSelectionConfig = {}): UseSelectionReturn {
|
|
77
|
+
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
|
78
|
+
|
|
79
|
+
const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
|
|
80
|
+
const selectionCount = selectedIds.size;
|
|
81
|
+
|
|
82
|
+
const select = useCallback((id: string) => {
|
|
83
|
+
setSelectedIds((prev) => {
|
|
84
|
+
if (!multiSelect) return new Set([id]);
|
|
85
|
+
if (maxSelections && prev.size >= maxSelections && !prev.has(id)) return prev;
|
|
86
|
+
const newSet = new Set(prev);
|
|
87
|
+
newSet.add(id);
|
|
88
|
+
return newSet;
|
|
89
|
+
});
|
|
90
|
+
}, [multiSelect, maxSelections]);
|
|
91
|
+
|
|
92
|
+
const deselect = useCallback((id: string) => {
|
|
93
|
+
setSelectedIds((prev) => {
|
|
94
|
+
const newSet = new Set(prev);
|
|
95
|
+
newSet.delete(id);
|
|
96
|
+
return newSet;
|
|
97
|
+
});
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const toggleSelection = useCallback((id: string) => {
|
|
101
|
+
setSelectedIds((prev) => {
|
|
102
|
+
if (!multiSelect) return new Set([id]);
|
|
103
|
+
const newSet = new Set(prev);
|
|
104
|
+
if (newSet.has(id)) {
|
|
105
|
+
newSet.delete(id);
|
|
106
|
+
} else {
|
|
107
|
+
if (maxSelections && newSet.size >= maxSelections) return prev;
|
|
108
|
+
newSet.add(id);
|
|
109
|
+
}
|
|
110
|
+
return newSet;
|
|
111
|
+
});
|
|
112
|
+
}, [multiSelect, maxSelections]);
|
|
113
|
+
|
|
114
|
+
const clearSelection = useCallback(() => setSelectedIds(new Set()), []);
|
|
115
|
+
|
|
116
|
+
return { selectedIds, isSelected, toggleSelection, select, deselect, clearSelection, selectionCount };
|
|
117
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useDraggableLayerGestures Hook
|
|
3
3
|
* Manages gesture handling for draggable layers
|
|
4
|
+
* PERFORMANCE: Uses runOnJS for state updates to prevent UI thread blocking
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { useState, useRef, useCallback, useEffect } from "react";
|
|
7
8
|
import { Gesture } from "react-native-gesture-handler";
|
|
9
|
+
import { runOnJS } from "react-native-reanimated";
|
|
8
10
|
|
|
9
11
|
interface UseDraggableLayerGesturesParams {
|
|
10
12
|
initialX: number;
|
|
@@ -74,22 +76,23 @@ export function useDraggableLayerGestures({
|
|
|
74
76
|
const gestureHandler = Gesture.Pan()
|
|
75
77
|
.onStart(() => {
|
|
76
78
|
startRef.current = { ...state, x: state.x, y: state.y };
|
|
77
|
-
onSelect();
|
|
79
|
+
runOnJS(onSelect)();
|
|
78
80
|
})
|
|
79
81
|
.onUpdate((event) => {
|
|
82
|
+
'worklet';
|
|
80
83
|
const newX = startRef.current.x + event.translationX;
|
|
81
84
|
const newY = startRef.current.y + event.translationY;
|
|
82
|
-
|
|
85
|
+
// Use runOnJS for state updates to prevent blocking UI thread
|
|
86
|
+
runOnJS(setState)({ x: newX, y: newY, width: state.width, height: state.height });
|
|
83
87
|
})
|
|
84
88
|
.onEnd(() => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
});
|
|
89
|
+
'worklet';
|
|
90
|
+
const clampedX = clamp(state.x, 0, canvasWidth - state.width);
|
|
91
|
+
const clampedY = clamp(state.y, 0, canvasHeight - state.height);
|
|
92
|
+
const newX = (clampedX / canvasWidth) * 100;
|
|
93
|
+
const newY = (clampedY / canvasHeight) * 100;
|
|
94
|
+
runOnJS(setState)({ x: clampedX, y: clampedY, width: state.width, height: state.height });
|
|
95
|
+
runOnJS(onPositionChange)(newX, newY);
|
|
93
96
|
});
|
|
94
97
|
|
|
95
98
|
const createResizeHandler = (
|
|
@@ -98,10 +101,12 @@ export function useDraggableLayerGestures({
|
|
|
98
101
|
) => {
|
|
99
102
|
return Gesture.Pan()
|
|
100
103
|
.onStart(() => {
|
|
104
|
+
'worklet';
|
|
101
105
|
startRef.current = { ...state };
|
|
102
|
-
onSelect();
|
|
106
|
+
runOnJS(onSelect)();
|
|
103
107
|
})
|
|
104
108
|
.onUpdate((event) => {
|
|
109
|
+
'worklet';
|
|
105
110
|
const newWidth = Math.max(MIN_SIZE, startRef.current.width + deltaX(event.translationX));
|
|
106
111
|
const newHeight = Math.max(MIN_SIZE, startRef.current.height + deltaY(event.translationY));
|
|
107
112
|
const clampedWidth = Math.min(newWidth, canvasWidth - startRef.current.x);
|
|
@@ -117,24 +122,22 @@ export function useDraggableLayerGestures({
|
|
|
117
122
|
newY = Math.max(0, startRef.current.y + (startRef.current.height - clampedHeight));
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
setState({ x: newX, y: newY, width: clampedWidth, height: clampedHeight });
|
|
125
|
+
runOnJS(setState)({ x: newX, y: newY, width: clampedWidth, height: clampedHeight });
|
|
121
126
|
})
|
|
122
127
|
.onEnd(() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
'worklet';
|
|
129
|
+
// Clamp position to canvas bounds
|
|
130
|
+
const clampedX = Math.max(0, Math.min(state.x, canvasWidth - state.width));
|
|
131
|
+
const clampedY = Math.max(0, Math.min(state.y, canvasHeight - state.height));
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
onSizeChange(newWidth, newHeight);
|
|
134
|
-
onPositionChange(newX, newY);
|
|
133
|
+
const newWidth = (state.width / canvasWidth) * 100;
|
|
134
|
+
const newHeight = (state.height / canvasHeight) * 100;
|
|
135
|
+
const newX = (clampedX / canvasWidth) * 100;
|
|
136
|
+
const newY = (clampedY / canvasHeight) * 100;
|
|
135
137
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
runOnJS(onSizeChange)(newWidth, newHeight);
|
|
139
|
+
runOnJS(onPositionChange)(newX, newY);
|
|
140
|
+
runOnJS(setState)({ x: clampedX, y: clampedY, width: state.width, height: state.height });
|
|
138
141
|
});
|
|
139
142
|
};
|
|
140
143
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useEditorPlayback Hook
|
|
3
3
|
* Single Responsibility: Playback control for editor
|
|
4
|
-
* Optimized
|
|
4
|
+
* PERFORMANCE: Optimized with frame throttling (30fps target) and stable refs
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
isTimeAtEnd,
|
|
13
13
|
} from "../../infrastructure/utils/time-calculations.utils";
|
|
14
14
|
|
|
15
|
+
// PERFORMANCE: Target 30fps instead of 60fps to reduce CPU load
|
|
16
|
+
// 60fps = 16.67ms, 30fps = 33.33ms between frames
|
|
17
|
+
const TARGET_FPS = 30;
|
|
18
|
+
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
|
19
|
+
|
|
15
20
|
interface UseEditorPlaybackParams {
|
|
16
21
|
currentScene: Scene | undefined;
|
|
17
22
|
}
|
|
@@ -33,6 +38,7 @@ export function useEditorPlayback({
|
|
|
33
38
|
// Use refs to avoid re-creating animation loop on every render
|
|
34
39
|
const animationFrameRef = useRef<number | null>(null);
|
|
35
40
|
const lastTimestampRef = useRef<number>(0);
|
|
41
|
+
const lastFrameTimeRef = useRef<number>(0); // Track frame time for throttling
|
|
36
42
|
const isPlayingRef = useRef(isPlaying);
|
|
37
43
|
const currentSceneRef = useRef(currentScene);
|
|
38
44
|
const durationRef = useRef(0);
|
|
@@ -49,7 +55,7 @@ export function useEditorPlayback({
|
|
|
49
55
|
}
|
|
50
56
|
}, [currentScene]);
|
|
51
57
|
|
|
52
|
-
// Video playback animation loop - optimized with refs
|
|
58
|
+
// Video playback animation loop - optimized with refs and frame throttling
|
|
53
59
|
useEffect(() => {
|
|
54
60
|
if (!currentScene) return;
|
|
55
61
|
|
|
@@ -60,9 +66,19 @@ export function useEditorPlayback({
|
|
|
60
66
|
return;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
// PERFORMANCE: Frame throttling - skip frames to maintain target FPS
|
|
70
|
+
if (lastFrameTimeRef.current > 0) {
|
|
71
|
+
const elapsed = timestamp - lastFrameTimeRef.current;
|
|
72
|
+
if (elapsed < FRAME_INTERVAL) {
|
|
73
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
if (lastTimestampRef.current === 0) {
|
|
64
79
|
lastTimestampRef.current = timestamp;
|
|
65
80
|
}
|
|
81
|
+
lastFrameTimeRef.current = timestamp;
|
|
66
82
|
|
|
67
83
|
const deltaTime = calculateDelta(timestamp, lastTimestampRef.current);
|
|
68
84
|
lastTimestampRef.current = timestamp;
|
|
@@ -87,6 +103,7 @@ export function useEditorPlayback({
|
|
|
87
103
|
|
|
88
104
|
if (isPlaying) {
|
|
89
105
|
lastTimestampRef.current = 0;
|
|
106
|
+
lastFrameTimeRef.current = 0;
|
|
90
107
|
animationFrameRef.current = requestAnimationFrame(animate);
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ImageLayer } from "../../domain/entities/video-project.types";
|
|
7
|
-
import { useLayerForm } from "./generic/use-layer-form.hook";
|
|
7
|
+
import { useLayerForm, type UseLayerFormConfig } from "./generic/use-layer-form.hook";
|
|
8
8
|
|
|
9
9
|
interface ImageLayerFormState {
|
|
10
10
|
imageUri: string;
|
|
11
11
|
opacity: number;
|
|
12
|
+
[key: string]: unknown;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
interface UseImageLayerFormReturn {
|
|
@@ -25,14 +26,14 @@ interface UseImageLayerFormReturn {
|
|
|
25
26
|
export function useImageLayerForm(
|
|
26
27
|
initialLayer?: ImageLayer,
|
|
27
28
|
): UseImageLayerFormReturn {
|
|
28
|
-
const
|
|
29
|
+
const config: UseLayerFormConfig<ImageLayerFormState> = {
|
|
29
30
|
initialValues: {
|
|
30
31
|
imageUri: initialLayer?.uri || "",
|
|
31
32
|
opacity: initialLayer?.opacity || 1,
|
|
32
33
|
},
|
|
33
34
|
validators: {
|
|
34
|
-
imageUri: (value) => {
|
|
35
|
-
if (!value || value.trim().length === 0) {
|
|
35
|
+
imageUri: (value: unknown) => {
|
|
36
|
+
if (!value || (typeof value === "string" && value.trim().length === 0)) {
|
|
36
37
|
return "Image URI is required";
|
|
37
38
|
}
|
|
38
39
|
return null;
|
|
@@ -41,8 +42,10 @@ export function useImageLayerForm(
|
|
|
41
42
|
buildData: (formState) => ({
|
|
42
43
|
uri: formState.imageUri,
|
|
43
44
|
opacity: formState.opacity,
|
|
44
|
-
}),
|
|
45
|
-
}
|
|
45
|
+
} as Partial<ImageLayer>),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const form = useLayerForm<ImageLayerFormState, Partial<ImageLayer>>(config);
|
|
46
49
|
|
|
47
50
|
const setImageUri = (uri: string) => form.updateField("imageUri", uri);
|
|
48
51
|
const setOpacity = (opacity: number) => form.updateField("opacity", opacity);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useMenuActions Hook
|
|
3
3
|
* Single Responsibility: Menu action handlers (layer actions menu)
|
|
4
|
+
* MEMORY: Properly cleans up timers to prevent memory leaks
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import { useCallback } from "react";
|
|
7
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
7
8
|
import { LayerActionsMenu } from "../components/LayerActionsMenu";
|
|
8
9
|
import type { Layer } from "../../domain/entities/video-project.types";
|
|
9
10
|
import type { UseEditorLayersReturn } from "./useEditorLayers";
|
|
@@ -30,6 +31,17 @@ export function useMenuActions({
|
|
|
30
31
|
}: UseMenuActionsParams): UseMenuActionsReturn {
|
|
31
32
|
const { openBottomSheet, closeBottomSheet } = bottomSheet;
|
|
32
33
|
|
|
34
|
+
// MEMORY: Track all timers for cleanup
|
|
35
|
+
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
36
|
+
|
|
37
|
+
// MEMORY: Clean up all timers on unmount
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
return () => {
|
|
40
|
+
timersRef.current.forEach((timer) => clearTimeout(timer));
|
|
41
|
+
timersRef.current = [];
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
33
45
|
const handleLayerActionsPress = useCallback(
|
|
34
46
|
(layer: Layer) => {
|
|
35
47
|
openBottomSheet({
|
|
@@ -39,15 +51,18 @@ export function useMenuActions({
|
|
|
39
51
|
layer={layer}
|
|
40
52
|
onEditText={() => {
|
|
41
53
|
closeBottomSheet();
|
|
42
|
-
setTimeout(() => handleEditLayer(), 300);
|
|
54
|
+
const timer = setTimeout(() => handleEditLayer(), 300);
|
|
55
|
+
timersRef.current.push(timer);
|
|
43
56
|
}}
|
|
44
57
|
onEditImage={() => {
|
|
45
58
|
closeBottomSheet();
|
|
46
|
-
setTimeout(() => handleEditImageLayer(layer.id), 300);
|
|
59
|
+
const timer = setTimeout(() => handleEditImageLayer(layer.id), 300);
|
|
60
|
+
timersRef.current.push(timer);
|
|
47
61
|
}}
|
|
48
62
|
onAnimate={() => {
|
|
49
63
|
closeBottomSheet();
|
|
50
|
-
setTimeout(() => handleAnimate(layer.id), 300);
|
|
64
|
+
const timer = setTimeout(() => handleAnimate(layer.id), 300);
|
|
65
|
+
timersRef.current.push(timer);
|
|
51
66
|
}}
|
|
52
67
|
onDuplicate={() => {
|
|
53
68
|
closeBottomSheet();
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
7
7
|
import type { TextLayer } from "../../domain/entities/video-project.types";
|
|
8
|
-
import { useLayerForm } from "./generic/use-layer-form.hook";
|
|
8
|
+
import { useLayerForm, type UseLayerFormConfig } from "./generic/use-layer-form.hook";
|
|
9
9
|
|
|
10
10
|
export interface TextLayerFormState {
|
|
11
11
|
text: string;
|
|
@@ -14,6 +14,7 @@ export interface TextLayerFormState {
|
|
|
14
14
|
fontWeight: "normal" | "bold" | "300" | "700";
|
|
15
15
|
color: string;
|
|
16
16
|
textAlign: "left" | "center" | "right";
|
|
17
|
+
[key: string]: unknown;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
interface UseTextLayerFormReturn {
|
|
@@ -36,7 +37,7 @@ export function useTextLayerForm(
|
|
|
36
37
|
): UseTextLayerFormReturn {
|
|
37
38
|
const tokens = useAppDesignTokens();
|
|
38
39
|
|
|
39
|
-
const
|
|
40
|
+
const config: UseLayerFormConfig<TextLayerFormState> = {
|
|
40
41
|
initialValues: {
|
|
41
42
|
text: initialLayer?.content || "",
|
|
42
43
|
fontSize: initialLayer?.fontSize || 48,
|
|
@@ -47,8 +48,8 @@ export function useTextLayerForm(
|
|
|
47
48
|
textAlign: initialLayer?.textAlign || "center",
|
|
48
49
|
},
|
|
49
50
|
validators: {
|
|
50
|
-
text: (value) => {
|
|
51
|
-
if (!value || value.trim().length === 0) {
|
|
51
|
+
text: (value: unknown) => {
|
|
52
|
+
if (!value || (typeof value === "string" && value.trim().length === 0)) {
|
|
52
53
|
return "Text content is required";
|
|
53
54
|
}
|
|
54
55
|
return null;
|
|
@@ -61,8 +62,10 @@ export function useTextLayerForm(
|
|
|
61
62
|
fontWeight: formState.fontWeight,
|
|
62
63
|
color: formState.color,
|
|
63
64
|
textAlign: formState.textAlign,
|
|
64
|
-
}),
|
|
65
|
-
}
|
|
65
|
+
} as Partial<TextLayer>),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const form = useLayerForm<TextLayerFormState, Partial<TextLayer>>(config);
|
|
66
69
|
|
|
67
70
|
const setText = (text: string) => form.updateField("text", text);
|
|
68
71
|
const setFontSize = (size: number) => form.updateField("fontSize", size);
|