@umituz/react-native-video-editor 1.1.62 → 1.1.64
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/infrastructure/services/base/base-layer-operations.service.ts +201 -0
- package/src/infrastructure/services/image-layer-operations.service.ts +36 -101
- package/src/infrastructure/services/shape-layer-operations.service.ts +38 -47
- package/src/infrastructure/services/text-layer-operations.service.ts +45 -97
- package/src/infrastructure/utils/data-clone.utils.ts +32 -51
- package/src/infrastructure/utils/video-calculations.utils.ts +7 -25
- package/src/presentation/components/CollageEditorCanvas.tsx +23 -232
- package/src/presentation/components/SubtitleListPanel.tsx +46 -252
- package/src/presentation/components/collage/CollageCanvas.tsx +94 -0
- package/src/presentation/components/collage/CollageControls.tsx +115 -0
- package/src/presentation/components/collage/CollageLayoutSelector.tsx +100 -0
- package/src/presentation/components/subtitle/SubtitleListHeader.tsx +58 -0
- package/src/presentation/components/subtitle/SubtitleListItem.tsx +96 -0
- package/src/presentation/components/subtitle/SubtitleModal.tsx +162 -0
- package/src/presentation/components/subtitle/useSubtitleForm.ts +95 -0
- package/src/presentation/hooks/generic/use-layer-form.hook.ts +124 -0
- package/src/presentation/hooks/useImageLayerForm.ts +25 -22
- package/src/presentation/hooks/useTextLayerForm.ts +35 -45
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleListHeader Component
|
|
3
|
+
* Header component for subtitle list panel
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, TouchableOpacity } from "react-native";
|
|
8
|
+
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
+
|
|
11
|
+
interface SubtitleListHeaderProps {
|
|
12
|
+
count: number;
|
|
13
|
+
onAdd: () => void;
|
|
14
|
+
title?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const SubtitleListHeader: React.FC<SubtitleListHeaderProps> = ({
|
|
18
|
+
count,
|
|
19
|
+
onAdd,
|
|
20
|
+
title,
|
|
21
|
+
}) => {
|
|
22
|
+
const tokens = useAppDesignTokens();
|
|
23
|
+
|
|
24
|
+
const styles = {
|
|
25
|
+
header: {
|
|
26
|
+
flexDirection: "row" as const,
|
|
27
|
+
alignItems: "center" as const,
|
|
28
|
+
justifyContent: "space-between" as const,
|
|
29
|
+
paddingHorizontal: tokens.spacing.md,
|
|
30
|
+
paddingVertical: tokens.spacing.sm,
|
|
31
|
+
borderBottomWidth: 1,
|
|
32
|
+
borderBottomColor: tokens.colors.border,
|
|
33
|
+
},
|
|
34
|
+
addBtn: {
|
|
35
|
+
width: 34,
|
|
36
|
+
height: 34,
|
|
37
|
+
borderRadius: 17,
|
|
38
|
+
backgroundColor: tokens.colors.primary,
|
|
39
|
+
alignItems: "center" as const,
|
|
40
|
+
justifyContent: "center" as const,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View style={styles.header}>
|
|
46
|
+
<AtomicText fontWeight="semibold" color="textPrimary">
|
|
47
|
+
{title || "Subtitles"} ({count})
|
|
48
|
+
</AtomicText>
|
|
49
|
+
<TouchableOpacity
|
|
50
|
+
style={styles.addBtn}
|
|
51
|
+
onPress={onAdd}
|
|
52
|
+
accessibilityRole="button"
|
|
53
|
+
>
|
|
54
|
+
<AtomicIcon name="add" size="sm" color="onPrimary" />
|
|
55
|
+
</TouchableOpacity>
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleListItem Component
|
|
3
|
+
* Individual subtitle item in the list
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, TouchableOpacity } from "react-native";
|
|
8
|
+
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
+
import { formatTimeDetailed } from "../../../infrastructure/utils/srt.utils";
|
|
11
|
+
import type { Subtitle } from "../../../domain/entities/video-project.types";
|
|
12
|
+
|
|
13
|
+
interface SubtitleListItemProps {
|
|
14
|
+
subtitle: Subtitle;
|
|
15
|
+
isActive: boolean;
|
|
16
|
+
onEdit: (subtitle: Subtitle) => void;
|
|
17
|
+
onSeek: (time: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const SubtitleListItem: React.FC<SubtitleListItemProps> = ({
|
|
21
|
+
subtitle,
|
|
22
|
+
isActive,
|
|
23
|
+
onEdit,
|
|
24
|
+
onSeek,
|
|
25
|
+
}) => {
|
|
26
|
+
const tokens = useAppDesignTokens();
|
|
27
|
+
|
|
28
|
+
const styles = {
|
|
29
|
+
item: {
|
|
30
|
+
flexDirection: "row" as const,
|
|
31
|
+
alignItems: "center" as const,
|
|
32
|
+
borderRadius: tokens.borders.radius.md,
|
|
33
|
+
marginHorizontal: tokens.spacing.md,
|
|
34
|
+
marginBottom: tokens.spacing.sm,
|
|
35
|
+
borderWidth: 1,
|
|
36
|
+
borderColor: isActive ? tokens.colors.primary : tokens.colors.border,
|
|
37
|
+
backgroundColor: isActive ? tokens.colors.primaryContainer : tokens.colors.surface,
|
|
38
|
+
overflow: "hidden" as const,
|
|
39
|
+
},
|
|
40
|
+
itemBar: {
|
|
41
|
+
width: 4,
|
|
42
|
+
alignSelf: "stretch" as const,
|
|
43
|
+
backgroundColor: isActive ? tokens.colors.primary : tokens.colors.surfaceVariant,
|
|
44
|
+
},
|
|
45
|
+
itemContent: {
|
|
46
|
+
flex: 1,
|
|
47
|
+
padding: tokens.spacing.sm,
|
|
48
|
+
},
|
|
49
|
+
itemTimeRow: {
|
|
50
|
+
flexDirection: "row" as const,
|
|
51
|
+
alignItems: "center" as const,
|
|
52
|
+
gap: tokens.spacing.xs,
|
|
53
|
+
marginBottom: 2,
|
|
54
|
+
},
|
|
55
|
+
itemEditBtn: {
|
|
56
|
+
width: 40,
|
|
57
|
+
height: 40,
|
|
58
|
+
alignItems: "center" as const,
|
|
59
|
+
justifyContent: "center" as const,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View style={styles.item}>
|
|
65
|
+
<View style={styles.itemBar} />
|
|
66
|
+
<TouchableOpacity
|
|
67
|
+
style={styles.itemContent}
|
|
68
|
+
onPress={() => onSeek(subtitle.startTime)}
|
|
69
|
+
activeOpacity={0.7}
|
|
70
|
+
>
|
|
71
|
+
<View style={styles.itemTimeRow}>
|
|
72
|
+
<AtomicText color="textSecondary">
|
|
73
|
+
{formatTimeDetailed(subtitle.startTime)}
|
|
74
|
+
</AtomicText>
|
|
75
|
+
<AtomicText color="textSecondary">
|
|
76
|
+
→
|
|
77
|
+
</AtomicText>
|
|
78
|
+
<AtomicText color="textSecondary">
|
|
79
|
+
{formatTimeDetailed(subtitle.endTime)}
|
|
80
|
+
</AtomicText>
|
|
81
|
+
</View>
|
|
82
|
+
<AtomicText color="textPrimary" numberOfLines={2}>
|
|
83
|
+
{subtitle.text}
|
|
84
|
+
</AtomicText>
|
|
85
|
+
</TouchableOpacity>
|
|
86
|
+
<TouchableOpacity
|
|
87
|
+
style={styles.itemEditBtn}
|
|
88
|
+
onPress={() => onEdit(subtitle)}
|
|
89
|
+
accessibilityRole="button"
|
|
90
|
+
accessibilityLabel="Edit subtitle"
|
|
91
|
+
>
|
|
92
|
+
<AtomicIcon name="edit" size="sm" color="textSecondary" />
|
|
93
|
+
</TouchableOpacity>
|
|
94
|
+
</View>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubtitleModal Component
|
|
3
|
+
* Modal for adding/editing subtitles
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, Modal, TextInput, TouchableOpacity, ScrollView } from "react-native";
|
|
8
|
+
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
9
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
+
import { SubtitleTimeInput } from "../SubtitleTimeInput";
|
|
11
|
+
import { SubtitleStylePicker } from "../SubtitleStylePicker";
|
|
12
|
+
import type { SubtitleStyle } from "../../../domain/entities/video-project.types";
|
|
13
|
+
|
|
14
|
+
interface SubtitleModalProps {
|
|
15
|
+
visible: boolean;
|
|
16
|
+
editing: boolean;
|
|
17
|
+
text: string;
|
|
18
|
+
startTime: number;
|
|
19
|
+
endTime: number;
|
|
20
|
+
style: SubtitleStyle;
|
|
21
|
+
onChangeText: (text: string) => void;
|
|
22
|
+
onChangeStartTime: (time: number) => void;
|
|
23
|
+
onChangeEndTime: (time: number) => void;
|
|
24
|
+
onChangeStyle: (style: SubtitleStyle) => void;
|
|
25
|
+
onSave: () => void;
|
|
26
|
+
onCancel: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SubtitleModal: React.FC<SubtitleModalProps> = ({
|
|
30
|
+
visible,
|
|
31
|
+
editing,
|
|
32
|
+
text,
|
|
33
|
+
startTime,
|
|
34
|
+
endTime,
|
|
35
|
+
style,
|
|
36
|
+
onChangeText,
|
|
37
|
+
onChangeStartTime,
|
|
38
|
+
onChangeEndTime,
|
|
39
|
+
onChangeStyle,
|
|
40
|
+
onSave,
|
|
41
|
+
onCancel,
|
|
42
|
+
}) => {
|
|
43
|
+
const tokens = useAppDesignTokens();
|
|
44
|
+
|
|
45
|
+
const styles = {
|
|
46
|
+
overlay: {
|
|
47
|
+
flex: 1,
|
|
48
|
+
backgroundColor: "rgba(0,0,0,0.5)",
|
|
49
|
+
justifyContent: "flex-end" as const,
|
|
50
|
+
},
|
|
51
|
+
modal: {
|
|
52
|
+
backgroundColor: tokens.colors.surface,
|
|
53
|
+
borderTopLeftRadius: tokens.borders.radius.xl,
|
|
54
|
+
borderTopRightRadius: tokens.borders.radius.xl,
|
|
55
|
+
paddingHorizontal: tokens.spacing.md,
|
|
56
|
+
paddingTop: tokens.spacing.md,
|
|
57
|
+
paddingBottom: tokens.spacing.xl,
|
|
58
|
+
maxHeight: "90%" as const,
|
|
59
|
+
},
|
|
60
|
+
handle: {
|
|
61
|
+
width: 36,
|
|
62
|
+
height: 4,
|
|
63
|
+
borderRadius: 2,
|
|
64
|
+
backgroundColor: tokens.colors.border,
|
|
65
|
+
alignSelf: "center" as const,
|
|
66
|
+
marginBottom: tokens.spacing.md,
|
|
67
|
+
},
|
|
68
|
+
textInput: {
|
|
69
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
70
|
+
borderRadius: tokens.borders.radius.md,
|
|
71
|
+
padding: tokens.spacing.md,
|
|
72
|
+
color: tokens.colors.textPrimary,
|
|
73
|
+
fontSize: 16,
|
|
74
|
+
minHeight: 80,
|
|
75
|
+
textAlignVertical: "top" as const,
|
|
76
|
+
marginBottom: tokens.spacing.md,
|
|
77
|
+
borderWidth: 1,
|
|
78
|
+
borderColor: tokens.colors.border,
|
|
79
|
+
},
|
|
80
|
+
timeRow: {
|
|
81
|
+
flexDirection: "row" as const,
|
|
82
|
+
gap: tokens.spacing.md,
|
|
83
|
+
marginBottom: tokens.spacing.md,
|
|
84
|
+
},
|
|
85
|
+
actionRow: {
|
|
86
|
+
flexDirection: "row" as const,
|
|
87
|
+
gap: tokens.spacing.md,
|
|
88
|
+
marginTop: tokens.spacing.md,
|
|
89
|
+
},
|
|
90
|
+
cancelBtn: {
|
|
91
|
+
flex: 1,
|
|
92
|
+
paddingVertical: tokens.spacing.md,
|
|
93
|
+
borderRadius: tokens.borders.radius.md,
|
|
94
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
95
|
+
alignItems: "center" as const,
|
|
96
|
+
},
|
|
97
|
+
saveBtn: {
|
|
98
|
+
flex: 1,
|
|
99
|
+
paddingVertical: tokens.spacing.md,
|
|
100
|
+
borderRadius: tokens.borders.radius.md,
|
|
101
|
+
backgroundColor: tokens.colors.primary,
|
|
102
|
+
alignItems: "center" as const,
|
|
103
|
+
},
|
|
104
|
+
saveBtnDisabled: { opacity: 0.4 },
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const isValid = text.trim().length > 0 && endTime > startTime;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
|
|
111
|
+
<View style={styles.overlay}>
|
|
112
|
+
<View style={styles.modal}>
|
|
113
|
+
<View style={styles.handle} />
|
|
114
|
+
|
|
115
|
+
<ScrollView>
|
|
116
|
+
<TextInput
|
|
117
|
+
style={styles.textInput}
|
|
118
|
+
placeholder="Enter subtitle text..."
|
|
119
|
+
placeholderTextColor={tokens.colors.textSecondary}
|
|
120
|
+
value={text}
|
|
121
|
+
onChangeText={onChangeText}
|
|
122
|
+
multiline
|
|
123
|
+
autoFocus
|
|
124
|
+
/>
|
|
125
|
+
|
|
126
|
+
<View style={styles.timeRow}>
|
|
127
|
+
<SubtitleTimeInput
|
|
128
|
+
label="Start"
|
|
129
|
+
value={startTime}
|
|
130
|
+
onChange={onChangeStartTime}
|
|
131
|
+
/>
|
|
132
|
+
<SubtitleTimeInput
|
|
133
|
+
label="End"
|
|
134
|
+
value={endTime}
|
|
135
|
+
onChange={onChangeEndTime}
|
|
136
|
+
/>
|
|
137
|
+
</View>
|
|
138
|
+
|
|
139
|
+
<SubtitleStylePicker style={style} previewText={text} onChange={onChangeStyle} t={(key) => key} />
|
|
140
|
+
|
|
141
|
+
<View style={styles.actionRow}>
|
|
142
|
+
<TouchableOpacity style={styles.cancelBtn} onPress={onCancel}>
|
|
143
|
+
<AtomicText fontWeight="medium" color="textPrimary">
|
|
144
|
+
Cancel
|
|
145
|
+
</AtomicText>
|
|
146
|
+
</TouchableOpacity>
|
|
147
|
+
<TouchableOpacity
|
|
148
|
+
style={[styles.saveBtn, !isValid && styles.saveBtnDisabled]}
|
|
149
|
+
onPress={onSave}
|
|
150
|
+
disabled={!isValid}
|
|
151
|
+
>
|
|
152
|
+
<AtomicText fontWeight="semibold" color="onPrimary">
|
|
153
|
+
{editing ? "Update" : "Add"}
|
|
154
|
+
</AtomicText>
|
|
155
|
+
</TouchableOpacity>
|
|
156
|
+
</View>
|
|
157
|
+
</ScrollView>
|
|
158
|
+
</View>
|
|
159
|
+
</View>
|
|
160
|
+
</Modal>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSubtitleForm Hook
|
|
3
|
+
* Manages subtitle form state and operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback } from "react";
|
|
7
|
+
import { DEFAULT_SUBTITLE_STYLE } from "../../../infrastructure/constants/subtitle.constants";
|
|
8
|
+
import type { Subtitle, SubtitleStyle } from "../../../domain/entities/video-project.types";
|
|
9
|
+
|
|
10
|
+
interface UseSubtitleFormReturn {
|
|
11
|
+
text: string;
|
|
12
|
+
startTime: number;
|
|
13
|
+
endTime: number;
|
|
14
|
+
style: SubtitleStyle;
|
|
15
|
+
editing: Subtitle | null;
|
|
16
|
+
showModal: boolean;
|
|
17
|
+
openAdd: (currentTime: number) => void;
|
|
18
|
+
openEdit: (subtitle: Subtitle) => void;
|
|
19
|
+
close: () => void;
|
|
20
|
+
save: (onAdd: (text: string, start: number, end: number, style: SubtitleStyle) => void, onUpdate: (id: string, patch: Partial<Omit<Subtitle, "id">>) => void) => void;
|
|
21
|
+
setText: (text: string) => void;
|
|
22
|
+
setStartTime: (time: number) => void;
|
|
23
|
+
setEndTime: (time: number) => void;
|
|
24
|
+
setStyle: (style: SubtitleStyle) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useSubtitleForm(): UseSubtitleFormReturn {
|
|
28
|
+
const [editing, setEditing] = useState<Subtitle | null>(null);
|
|
29
|
+
const [showModal, setShowModal] = useState(false);
|
|
30
|
+
const [text, setText] = useState("");
|
|
31
|
+
const [startTime, setStartTime] = useState(0);
|
|
32
|
+
const [endTime, setEndTime] = useState(3);
|
|
33
|
+
const [style, setStyle] = useState<SubtitleStyle>({ ...DEFAULT_SUBTITLE_STYLE });
|
|
34
|
+
|
|
35
|
+
const openAdd = useCallback((currentTime: number) => {
|
|
36
|
+
setEditing(null);
|
|
37
|
+
setText("");
|
|
38
|
+
setStartTime(Math.floor(currentTime));
|
|
39
|
+
setEndTime(Math.floor(currentTime) + 3);
|
|
40
|
+
setStyle({ ...DEFAULT_SUBTITLE_STYLE });
|
|
41
|
+
setShowModal(true);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const openEdit = useCallback((subtitle: Subtitle) => {
|
|
45
|
+
setEditing(subtitle);
|
|
46
|
+
setText(subtitle.text);
|
|
47
|
+
setStartTime(subtitle.startTime);
|
|
48
|
+
setEndTime(subtitle.endTime);
|
|
49
|
+
setStyle({ ...subtitle.style });
|
|
50
|
+
setShowModal(true);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const close = useCallback(() => {
|
|
54
|
+
setShowModal(false);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const save = useCallback((
|
|
58
|
+
onAdd: (text: string, start: number, end: number, style: SubtitleStyle) => void,
|
|
59
|
+
onUpdate: (id: string, patch: Partial<Omit<Subtitle, "id">>) => void,
|
|
60
|
+
) => {
|
|
61
|
+
if (!text.trim()) return;
|
|
62
|
+
|
|
63
|
+
const resolvedEnd = Math.max(endTime, startTime + 0.5);
|
|
64
|
+
|
|
65
|
+
if (editing) {
|
|
66
|
+
onUpdate(editing.id, {
|
|
67
|
+
text: text.trim(),
|
|
68
|
+
startTime,
|
|
69
|
+
endTime: resolvedEnd,
|
|
70
|
+
style,
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
onAdd(text.trim(), startTime, resolvedEnd, style);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
close();
|
|
77
|
+
}, [text, editing, startTime, endTime, style, close]);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
text,
|
|
81
|
+
startTime,
|
|
82
|
+
endTime,
|
|
83
|
+
style,
|
|
84
|
+
editing,
|
|
85
|
+
showModal,
|
|
86
|
+
openAdd,
|
|
87
|
+
openEdit,
|
|
88
|
+
close,
|
|
89
|
+
save,
|
|
90
|
+
setText,
|
|
91
|
+
setStartTime,
|
|
92
|
+
setEndTime,
|
|
93
|
+
setStyle,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Layer Form Hook
|
|
3
|
+
* Type-safe form management for layer editors
|
|
4
|
+
* Eliminates code duplication across layer form hooks
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback } from "react";
|
|
8
|
+
import type { Layer, ImageLayer, TextLayer } from "../../../domain/entities/video-project.types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Form field validator function type
|
|
12
|
+
*/
|
|
13
|
+
export type ValidatorFn<T, K extends keyof T = keyof T> = (value: T[K]) => string | null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Layer form configuration
|
|
17
|
+
*/
|
|
18
|
+
export interface UseLayerFormConfig<T extends Record<string, unknown>> {
|
|
19
|
+
initialValues: Partial<T>;
|
|
20
|
+
validators?: Record<string, (value: unknown) => string | null>;
|
|
21
|
+
buildData: (formState: T) => Partial<Layer> | Partial<ImageLayer> | Partial<TextLayer>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Layer form return type
|
|
26
|
+
*/
|
|
27
|
+
export interface UseLayerFormReturn<T extends Record<string, unknown>, R = Partial<Layer>> {
|
|
28
|
+
formState: T;
|
|
29
|
+
updateField: <K extends keyof T>(field: K, value: T[K]) => void;
|
|
30
|
+
setFormState: (state: T | ((prev: T) => T)) => void;
|
|
31
|
+
buildLayerData: () => R;
|
|
32
|
+
isValid: boolean;
|
|
33
|
+
errors: Partial<Record<keyof T, string | null>>;
|
|
34
|
+
validateField: <K extends keyof T>(field: K) => string | null;
|
|
35
|
+
validateAll: () => boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generic hook for managing layer form state
|
|
40
|
+
* Provides type-safe form management with validation support
|
|
41
|
+
*/
|
|
42
|
+
export function useLayerForm<T extends Record<string, unknown>, R = Partial<Layer>>(
|
|
43
|
+
config: UseLayerFormConfig<T>,
|
|
44
|
+
): UseLayerFormReturn<T, R> {
|
|
45
|
+
const { initialValues, validators = {}, buildData } = config;
|
|
46
|
+
|
|
47
|
+
const [formState, setFormState] = useState<T>(
|
|
48
|
+
initialValues as T,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const [errors, setErrors] = useState<Partial<Record<keyof T, string | null>>>(
|
|
52
|
+
{},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const updateField = useCallback(
|
|
56
|
+
<K extends keyof T>(field: K, value: T[K]) => {
|
|
57
|
+
setFormState((prev) => ({
|
|
58
|
+
...prev,
|
|
59
|
+
[field]: value,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Clear error for this field
|
|
63
|
+
if (errors[field as keyof typeof errors]) {
|
|
64
|
+
setErrors((prev) => ({
|
|
65
|
+
...prev,
|
|
66
|
+
[field]: null,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[errors],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const validateField = useCallback(
|
|
74
|
+
<K extends keyof T>(field: K): string | null => {
|
|
75
|
+
const validator = validators[String(field)];
|
|
76
|
+
if (!validator) return null;
|
|
77
|
+
|
|
78
|
+
const error = validator(formState[field]);
|
|
79
|
+
setErrors((prev) => ({
|
|
80
|
+
...prev,
|
|
81
|
+
[field]: error,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
return error;
|
|
85
|
+
},
|
|
86
|
+
[formState, validators],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const validateAll = useCallback((): boolean => {
|
|
90
|
+
let hasError = false;
|
|
91
|
+
const newErrors: Partial<Record<keyof T, string | null>> = {};
|
|
92
|
+
|
|
93
|
+
for (const field in validators) {
|
|
94
|
+
const validator = validators[field];
|
|
95
|
+
if (validator) {
|
|
96
|
+
const error = validator(formState[field as keyof T]);
|
|
97
|
+
if (error) {
|
|
98
|
+
newErrors[field as keyof T] = error;
|
|
99
|
+
hasError = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setErrors(newErrors);
|
|
105
|
+
return !hasError;
|
|
106
|
+
}, [formState, validators]);
|
|
107
|
+
|
|
108
|
+
const buildLayerData = useCallback((): R => {
|
|
109
|
+
return buildData(formState) as R;
|
|
110
|
+
}, [formState, buildData]);
|
|
111
|
+
|
|
112
|
+
const isValid = Object.values(errors).every((error) => error === null);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
formState,
|
|
116
|
+
updateField,
|
|
117
|
+
setFormState,
|
|
118
|
+
buildLayerData,
|
|
119
|
+
isValid,
|
|
120
|
+
errors,
|
|
121
|
+
validateField,
|
|
122
|
+
validateAll,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* Manages form state for image layer editor
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useState, useCallback } from "react";
|
|
7
6
|
import type { ImageLayer } from "../../domain/entities/video-project.types";
|
|
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,33 +26,35 @@ interface UseImageLayerFormReturn {
|
|
|
25
26
|
export function useImageLayerForm(
|
|
26
27
|
initialLayer?: ImageLayer,
|
|
27
28
|
): UseImageLayerFormReturn {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return {
|
|
29
|
+
const config: UseLayerFormConfig<ImageLayerFormState> = {
|
|
30
|
+
initialValues: {
|
|
31
|
+
imageUri: initialLayer?.uri || "",
|
|
32
|
+
opacity: initialLayer?.opacity || 1,
|
|
33
|
+
},
|
|
34
|
+
validators: {
|
|
35
|
+
imageUri: (value: unknown) => {
|
|
36
|
+
if (!value || (typeof value === "string" && value.trim().length === 0)) {
|
|
37
|
+
return "Image URI is required";
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
buildData: (formState) => ({
|
|
43
43
|
uri: formState.imageUri,
|
|
44
44
|
opacity: formState.opacity,
|
|
45
|
-
}
|
|
46
|
-
}
|
|
45
|
+
} as Partial<ImageLayer>),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const form = useLayerForm<ImageLayerFormState, Partial<ImageLayer>>(config);
|
|
47
49
|
|
|
48
|
-
const
|
|
50
|
+
const setImageUri = (uri: string) => form.updateField("imageUri", uri);
|
|
51
|
+
const setOpacity = (opacity: number) => form.updateField("opacity", opacity);
|
|
49
52
|
|
|
50
53
|
return {
|
|
51
|
-
formState,
|
|
54
|
+
formState: form.formState,
|
|
52
55
|
setImageUri,
|
|
53
56
|
setOpacity,
|
|
54
|
-
buildLayerData,
|
|
55
|
-
isValid,
|
|
57
|
+
buildLayerData: form.buildLayerData,
|
|
58
|
+
isValid: form.isValid,
|
|
56
59
|
};
|
|
57
60
|
}
|