@umituz/react-native-video-editor 1.1.64 → 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/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 +54 -27
- package/src/presentation/components/SubtitleStylePicker.tsx +36 -156
- 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/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/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/useMenuActions.tsx +19 -4
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Color Picker Horizontal Component
|
|
3
3
|
* Horizontal scrolling color picker for shape layer
|
|
4
|
+
* REFACTORED: Uses generic Selector with colorPreview mode (28 lines)
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import React from "react";
|
|
7
|
-
import { View
|
|
8
|
-
import { AtomicText
|
|
7
|
+
import React, { useMemo } from "react";
|
|
8
|
+
import { View } from "react-native";
|
|
9
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
10
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
+
import { Selector, type SelectorItem } from "../generic/Selector";
|
|
10
12
|
import { SHAPE_COLORS } from "../../../infrastructure/constants/shape-layer.constants";
|
|
11
13
|
|
|
12
14
|
interface ColorPickerHorizontalProps {
|
|
@@ -22,8 +24,13 @@ export const ColorPickerHorizontal: React.FC<ColorPickerHorizontalProps> = ({
|
|
|
22
24
|
}) => {
|
|
23
25
|
const tokens = useAppDesignTokens();
|
|
24
26
|
|
|
27
|
+
const items = useMemo<SelectorItem[]>(
|
|
28
|
+
() => SHAPE_COLORS.map((color) => ({ value: color.value, label: "", color: color.value })),
|
|
29
|
+
[],
|
|
30
|
+
);
|
|
31
|
+
|
|
25
32
|
return (
|
|
26
|
-
<View style={
|
|
33
|
+
<View style={{ marginBottom: tokens.spacing.md }}>
|
|
27
34
|
<AtomicText
|
|
28
35
|
type="bodyMedium"
|
|
29
36
|
style={{
|
|
@@ -34,56 +41,14 @@ export const ColorPickerHorizontal: React.FC<ColorPickerHorizontalProps> = ({
|
|
|
34
41
|
>
|
|
35
42
|
{title}
|
|
36
43
|
</AtomicText>
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
key={color.value}
|
|
46
|
-
style={[
|
|
47
|
-
styles.colorButton,
|
|
48
|
-
{
|
|
49
|
-
backgroundColor: color.value,
|
|
50
|
-
borderColor:
|
|
51
|
-
selectedColor === color.value
|
|
52
|
-
? tokens.colors.primary
|
|
53
|
-
: tokens.colors.borderLight,
|
|
54
|
-
borderWidth: selectedColor === color.value ? 3 : 2,
|
|
55
|
-
},
|
|
56
|
-
]}
|
|
57
|
-
onPress={() => onColorChange(color.value)}
|
|
58
|
-
>
|
|
59
|
-
{selectedColor === color.value && (
|
|
60
|
-
<AtomicIcon
|
|
61
|
-
name="checkmark-outline"
|
|
62
|
-
size="sm"
|
|
63
|
-
color={color.value === "#FFFFFF" ? "primary" : "onSurface"}
|
|
64
|
-
/>
|
|
65
|
-
)}
|
|
66
|
-
</TouchableOpacity>
|
|
67
|
-
))}
|
|
68
|
-
</ScrollView>
|
|
44
|
+
<Selector
|
|
45
|
+
items={items}
|
|
46
|
+
selectedValue={selectedColor}
|
|
47
|
+
onSelect={onColorChange}
|
|
48
|
+
orientation="horizontal"
|
|
49
|
+
colorPreview
|
|
50
|
+
testID="color-picker-horizontal"
|
|
51
|
+
/>
|
|
69
52
|
</View>
|
|
70
53
|
);
|
|
71
54
|
};
|
|
72
|
-
|
|
73
|
-
const styles = StyleSheet.create({
|
|
74
|
-
section: {
|
|
75
|
-
marginBottom: 24,
|
|
76
|
-
},
|
|
77
|
-
colorsScroll: {
|
|
78
|
-
marginHorizontal: -16,
|
|
79
|
-
paddingHorizontal: 16,
|
|
80
|
-
},
|
|
81
|
-
colorButton: {
|
|
82
|
-
width: 50,
|
|
83
|
-
height: 50,
|
|
84
|
-
borderRadius: 25,
|
|
85
|
-
marginRight: 12,
|
|
86
|
-
alignItems: "center",
|
|
87
|
-
justifyContent: "center",
|
|
88
|
-
},
|
|
89
|
-
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Color Picker Component
|
|
3
3
|
* Color picker for text layer
|
|
4
|
+
* REFACTORED: Uses generic Selector with colorPreview mode (35 lines)
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import React from "react";
|
|
7
|
-
import { View
|
|
8
|
-
import {
|
|
9
|
-
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
7
|
+
import React, { useMemo } from "react";
|
|
8
|
+
import { View } from "react-native";
|
|
9
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
10
10
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
+
import { useLocalization } from "@umituz/react-native-settings";
|
|
12
|
+
import { Selector, type SelectorItem } from "../generic/Selector";
|
|
11
13
|
import { TEXT_COLORS } from "../../../infrastructure/constants/text-layer.constants";
|
|
12
14
|
|
|
13
15
|
interface ColorPickerProps {
|
|
@@ -22,8 +24,13 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
22
24
|
const { t } = useLocalization();
|
|
23
25
|
const tokens = useAppDesignTokens();
|
|
24
26
|
|
|
27
|
+
const items = useMemo<SelectorItem[]>(
|
|
28
|
+
() => TEXT_COLORS.map((color) => ({ value: color, label: "", color })),
|
|
29
|
+
[],
|
|
30
|
+
);
|
|
31
|
+
|
|
25
32
|
return (
|
|
26
|
-
<View style={
|
|
33
|
+
<View style={{ marginBottom: tokens.spacing.md }}>
|
|
27
34
|
<AtomicText
|
|
28
35
|
type="bodyMedium"
|
|
29
36
|
style={{
|
|
@@ -34,55 +41,14 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
|
34
41
|
>
|
|
35
42
|
{t("editor.properties.color")}
|
|
36
43
|
</AtomicText>
|
|
37
|
-
<
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
borderColor:
|
|
46
|
-
selectedColor === color
|
|
47
|
-
? tokens.colors.primary
|
|
48
|
-
: tokens.colors.borderLight,
|
|
49
|
-
borderWidth: selectedColor === color ? 3 : 1,
|
|
50
|
-
},
|
|
51
|
-
]}
|
|
52
|
-
onPress={() => onColorChange(color)}
|
|
53
|
-
>
|
|
54
|
-
{selectedColor === color && (
|
|
55
|
-
<AtomicIcon
|
|
56
|
-
name="checkmark-outline"
|
|
57
|
-
size="sm"
|
|
58
|
-
color={
|
|
59
|
-
color === "#FFFFFF" || color === "#FCD34D"
|
|
60
|
-
? "primary"
|
|
61
|
-
: "onSurface"
|
|
62
|
-
}
|
|
63
|
-
/>
|
|
64
|
-
)}
|
|
65
|
-
</TouchableOpacity>
|
|
66
|
-
))}
|
|
67
|
-
</View>
|
|
44
|
+
<Selector
|
|
45
|
+
items={items}
|
|
46
|
+
selectedValue={selectedColor}
|
|
47
|
+
onSelect={onColorChange}
|
|
48
|
+
orientation="grid"
|
|
49
|
+
colorPreview
|
|
50
|
+
testID="color-picker"
|
|
51
|
+
/>
|
|
68
52
|
</View>
|
|
69
53
|
);
|
|
70
54
|
};
|
|
71
|
-
|
|
72
|
-
const styles = StyleSheet.create({
|
|
73
|
-
section: {
|
|
74
|
-
marginBottom: 24,
|
|
75
|
-
},
|
|
76
|
-
colorGrid: {
|
|
77
|
-
flexDirection: "row",
|
|
78
|
-
flexWrap: "wrap",
|
|
79
|
-
gap: 12,
|
|
80
|
-
},
|
|
81
|
-
colorButton: {
|
|
82
|
-
width: 50,
|
|
83
|
-
height: 50,
|
|
84
|
-
borderRadius: 25,
|
|
85
|
-
alignItems: "center",
|
|
86
|
-
justifyContent: "center",
|
|
87
|
-
},
|
|
88
|
-
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Font Size Selector Component
|
|
3
3
|
* Font size selector for text layer
|
|
4
|
+
* REFACTORED: Uses generic Selector component (45 lines)
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import React from "react";
|
|
7
|
-
import { View
|
|
8
|
-
import { useLocalization } from "@umituz/react-native-settings";
|
|
7
|
+
import React, { useMemo } from "react";
|
|
8
|
+
import { View } from "react-native";
|
|
9
9
|
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
10
10
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
+
import { useLocalization } from "@umituz/react-native-settings";
|
|
12
|
+
import { Selector, type SelectorItem } from "../generic/Selector";
|
|
11
13
|
import { FONT_SIZES } from "../../../infrastructure/constants/text-layer.constants";
|
|
12
14
|
|
|
13
15
|
interface FontSizeSelectorProps {
|
|
@@ -22,8 +24,13 @@ export const FontSizeSelector: React.FC<FontSizeSelectorProps> = ({
|
|
|
22
24
|
const { t } = useLocalization();
|
|
23
25
|
const tokens = useAppDesignTokens();
|
|
24
26
|
|
|
27
|
+
const items = useMemo<SelectorItem<number>[]>(
|
|
28
|
+
() => FONT_SIZES.map((size) => ({ value: size, label: `${size}px` })),
|
|
29
|
+
[],
|
|
30
|
+
);
|
|
31
|
+
|
|
25
32
|
return (
|
|
26
|
-
<View style={
|
|
33
|
+
<View style={{ marginBottom: tokens.spacing.md }}>
|
|
27
34
|
<AtomicText
|
|
28
35
|
type="bodyMedium"
|
|
29
36
|
style={{
|
|
@@ -34,51 +41,13 @@ export const FontSizeSelector: React.FC<FontSizeSelectorProps> = ({
|
|
|
34
41
|
>
|
|
35
42
|
{t("editor.properties.font_size")}: {fontSize}px
|
|
36
43
|
</AtomicText>
|
|
37
|
-
<
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
backgroundColor:
|
|
45
|
-
fontSize === size
|
|
46
|
-
? tokens.colors.primary
|
|
47
|
-
: tokens.colors.surface,
|
|
48
|
-
borderColor:
|
|
49
|
-
fontSize === size
|
|
50
|
-
? tokens.colors.primary
|
|
51
|
-
: tokens.colors.borderLight,
|
|
52
|
-
},
|
|
53
|
-
]}
|
|
54
|
-
onPress={() => onFontSizeChange(size)}
|
|
55
|
-
>
|
|
56
|
-
<AtomicText
|
|
57
|
-
type="bodySmall"
|
|
58
|
-
style={{
|
|
59
|
-
color:
|
|
60
|
-
fontSize === size ? tokens.colors.onPrimary : tokens.colors.textPrimary,
|
|
61
|
-
fontWeight: fontSize === size ? "600" : "400",
|
|
62
|
-
}}
|
|
63
|
-
>
|
|
64
|
-
{size}
|
|
65
|
-
</AtomicText>
|
|
66
|
-
</TouchableOpacity>
|
|
67
|
-
))}
|
|
68
|
-
</ScrollView>
|
|
44
|
+
<Selector
|
|
45
|
+
items={items}
|
|
46
|
+
selectedValue={fontSize}
|
|
47
|
+
onSelect={onFontSizeChange}
|
|
48
|
+
orientation="horizontal"
|
|
49
|
+
testID="font-size-selector"
|
|
50
|
+
/>
|
|
69
51
|
</View>
|
|
70
52
|
);
|
|
71
53
|
};
|
|
72
|
-
|
|
73
|
-
const styles = StyleSheet.create({
|
|
74
|
-
section: {
|
|
75
|
-
marginBottom: 24,
|
|
76
|
-
},
|
|
77
|
-
sizeButton: {
|
|
78
|
-
paddingHorizontal: 16,
|
|
79
|
-
paddingVertical: 10,
|
|
80
|
-
borderRadius: 8,
|
|
81
|
-
borderWidth: 1,
|
|
82
|
-
marginRight: 8,
|
|
83
|
-
},
|
|
84
|
-
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Option Selector Component
|
|
3
3
|
* Reusable selector for font family, font weight, etc.
|
|
4
|
+
* REFACTORED: Uses generic Selector component (29 lines)
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import React from "react";
|
|
7
|
-
import { View
|
|
7
|
+
import React, { useMemo } from "react";
|
|
8
|
+
import { View } from "react-native";
|
|
8
9
|
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
10
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
+
import { Selector, type SelectorItem } from "../generic/Selector";
|
|
10
12
|
|
|
11
13
|
interface Option {
|
|
12
14
|
label: string;
|
|
@@ -28,8 +30,13 @@ export const OptionSelector: React.FC<OptionSelectorProps> = ({
|
|
|
28
30
|
}) => {
|
|
29
31
|
const tokens = useAppDesignTokens();
|
|
30
32
|
|
|
33
|
+
const items = useMemo<SelectorItem<string>[]>(
|
|
34
|
+
() => options.map((option) => ({ value: option.value, label: option.label })),
|
|
35
|
+
[options],
|
|
36
|
+
);
|
|
37
|
+
|
|
31
38
|
return (
|
|
32
|
-
<View style={
|
|
39
|
+
<View style={{ marginBottom: tokens.spacing.md }}>
|
|
33
40
|
<AtomicText
|
|
34
41
|
type="bodyMedium"
|
|
35
42
|
style={{
|
|
@@ -40,57 +47,13 @@ export const OptionSelector: React.FC<OptionSelectorProps> = ({
|
|
|
40
47
|
>
|
|
41
48
|
{title}
|
|
42
49
|
</AtomicText>
|
|
43
|
-
<
|
|
44
|
-
{
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
backgroundColor:
|
|
51
|
-
selectedValue === option.value
|
|
52
|
-
? tokens.colors.primary + "20"
|
|
53
|
-
: tokens.colors.surface,
|
|
54
|
-
borderColor:
|
|
55
|
-
selectedValue === option.value
|
|
56
|
-
? tokens.colors.primary
|
|
57
|
-
: tokens.colors.borderLight,
|
|
58
|
-
},
|
|
59
|
-
]}
|
|
60
|
-
onPress={() => onValueChange(option.value)}
|
|
61
|
-
>
|
|
62
|
-
<AtomicText
|
|
63
|
-
type="labelSmall"
|
|
64
|
-
style={{
|
|
65
|
-
color:
|
|
66
|
-
selectedValue === option.value
|
|
67
|
-
? tokens.colors.primary
|
|
68
|
-
: tokens.colors.textPrimary,
|
|
69
|
-
fontWeight: selectedValue === option.value ? "600" : "400",
|
|
70
|
-
}}
|
|
71
|
-
>
|
|
72
|
-
{option.label}
|
|
73
|
-
</AtomicText>
|
|
74
|
-
</TouchableOpacity>
|
|
75
|
-
))}
|
|
76
|
-
</View>
|
|
50
|
+
<Selector
|
|
51
|
+
items={items}
|
|
52
|
+
selectedValue={selectedValue}
|
|
53
|
+
onSelect={onValueChange}
|
|
54
|
+
orientation="grid"
|
|
55
|
+
testID="option-selector"
|
|
56
|
+
/>
|
|
77
57
|
</View>
|
|
78
58
|
);
|
|
79
59
|
};
|
|
80
|
-
|
|
81
|
-
const styles = StyleSheet.create({
|
|
82
|
-
section: {
|
|
83
|
-
marginBottom: 24,
|
|
84
|
-
},
|
|
85
|
-
optionsGrid: {
|
|
86
|
-
flexDirection: "row",
|
|
87
|
-
flexWrap: "wrap",
|
|
88
|
-
gap: 8,
|
|
89
|
-
},
|
|
90
|
-
optionButton: {
|
|
91
|
-
paddingHorizontal: 16,
|
|
92
|
-
paddingVertical: 10,
|
|
93
|
-
borderRadius: 8,
|
|
94
|
-
borderWidth: 1,
|
|
95
|
-
},
|
|
96
|
-
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Text Align Selector Component
|
|
3
3
|
* Text alignment selector for text layer
|
|
4
|
+
* REFACTORED: Uses generic Selector component (38 lines)
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import React from "react";
|
|
7
|
-
import { View
|
|
8
|
-
import {
|
|
9
|
-
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
7
|
+
import React, { useMemo } from "react";
|
|
8
|
+
import { View } from "react-native";
|
|
9
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
10
10
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
+
import { useLocalization } from "@umituz/react-native-settings";
|
|
12
|
+
import { Selector, type SelectorItem } from "../generic/Selector";
|
|
11
13
|
import { TEXT_ALIGNS } from "../../../infrastructure/constants/text-layer.constants";
|
|
12
14
|
|
|
13
15
|
interface TextAlignSelectorProps {
|
|
@@ -22,8 +24,17 @@ export const TextAlignSelector: React.FC<TextAlignSelectorProps> = ({
|
|
|
22
24
|
const { t } = useLocalization();
|
|
23
25
|
const tokens = useAppDesignTokens();
|
|
24
26
|
|
|
27
|
+
const items = useMemo<SelectorItem<"left" | "center" | "right">[]>(
|
|
28
|
+
() => TEXT_ALIGNS.map((align) => ({
|
|
29
|
+
value: align.value,
|
|
30
|
+
label: "",
|
|
31
|
+
icon: align.icon,
|
|
32
|
+
})),
|
|
33
|
+
[],
|
|
34
|
+
);
|
|
35
|
+
|
|
25
36
|
return (
|
|
26
|
-
<View style={
|
|
37
|
+
<View style={{ marginBottom: tokens.spacing.md }}>
|
|
27
38
|
<AtomicText
|
|
28
39
|
type="bodyMedium"
|
|
29
40
|
style={{
|
|
@@ -34,51 +45,13 @@ export const TextAlignSelector: React.FC<TextAlignSelectorProps> = ({
|
|
|
34
45
|
>
|
|
35
46
|
{t("editor.properties.text_align")}
|
|
36
47
|
</AtomicText>
|
|
37
|
-
<
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
backgroundColor:
|
|
45
|
-
textAlign === align.value
|
|
46
|
-
? tokens.colors.primary
|
|
47
|
-
: tokens.colors.surface,
|
|
48
|
-
borderColor:
|
|
49
|
-
textAlign === align.value
|
|
50
|
-
? tokens.colors.primary
|
|
51
|
-
: tokens.colors.borderLight,
|
|
52
|
-
},
|
|
53
|
-
]}
|
|
54
|
-
onPress={() => onTextAlignChange(align.value)}
|
|
55
|
-
>
|
|
56
|
-
<AtomicIcon
|
|
57
|
-
name={align.icon}
|
|
58
|
-
size="md"
|
|
59
|
-
color={textAlign === align.value ? "onSurface" : "secondary"}
|
|
60
|
-
/>
|
|
61
|
-
</TouchableOpacity>
|
|
62
|
-
))}
|
|
63
|
-
</View>
|
|
48
|
+
<Selector
|
|
49
|
+
items={items}
|
|
50
|
+
selectedValue={textAlign}
|
|
51
|
+
onSelect={onTextAlignChange}
|
|
52
|
+
icon
|
|
53
|
+
testID="text-align-selector"
|
|
54
|
+
/>
|
|
64
55
|
</View>
|
|
65
56
|
);
|
|
66
57
|
};
|
|
67
|
-
|
|
68
|
-
const styles = StyleSheet.create({
|
|
69
|
-
section: {
|
|
70
|
-
marginBottom: 24,
|
|
71
|
-
},
|
|
72
|
-
alignButtons: {
|
|
73
|
-
flexDirection: "row",
|
|
74
|
-
gap: 8,
|
|
75
|
-
},
|
|
76
|
-
alignButton: {
|
|
77
|
-
flex: 1,
|
|
78
|
-
paddingVertical: 12,
|
|
79
|
-
borderRadius: 8,
|
|
80
|
-
borderWidth: 2,
|
|
81
|
-
alignItems: "center",
|
|
82
|
-
justifyContent: "center",
|
|
83
|
-
},
|
|
84
|
-
});
|
|
@@ -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
|
+
}
|