@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
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* Full subtitle editor panel: list + add/edit modal
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React, {
|
|
7
|
-
import { View, ScrollView
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, ScrollView } from "react-native";
|
|
8
8
|
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
9
9
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
10
|
+
import { SubtitleListHeader } from "./subtitle/SubtitleListHeader";
|
|
11
|
+
import { SubtitleListItem } from "./subtitle/SubtitleListItem";
|
|
12
|
+
import { SubtitleModal } from "./subtitle/SubtitleModal";
|
|
13
|
+
import { useSubtitleForm } from "./subtitle/useSubtitleForm";
|
|
14
14
|
import type { Subtitle, SubtitleStyle } from "../../domain/entities/video-project.types";
|
|
15
15
|
|
|
16
16
|
interface SubtitleListPanelProps {
|
|
@@ -34,272 +34,66 @@ export const SubtitleListPanel: React.FC<SubtitleListPanelProps> = ({
|
|
|
34
34
|
}) => {
|
|
35
35
|
const tokens = useAppDesignTokens();
|
|
36
36
|
|
|
37
|
-
const
|
|
38
|
-
const [editing, setEditing] = useState<Subtitle | null>(null);
|
|
39
|
-
const [text, setText] = useState("");
|
|
40
|
-
const [startTime, setStartTime] = useState(0);
|
|
41
|
-
const [endTime, setEndTime] = useState(3);
|
|
42
|
-
const [style, setStyle] = useState<SubtitleStyle>({ ...DEFAULT_SUBTITLE_STYLE });
|
|
43
|
-
|
|
44
|
-
const styles = useMemo(() => StyleSheet.create({
|
|
45
|
-
header: {
|
|
46
|
-
flexDirection: "row",
|
|
47
|
-
alignItems: "center",
|
|
48
|
-
justifyContent: "space-between",
|
|
49
|
-
paddingHorizontal: tokens.spacing.md,
|
|
50
|
-
paddingVertical: tokens.spacing.sm,
|
|
51
|
-
borderBottomWidth: 1,
|
|
52
|
-
borderBottomColor: tokens.colors.border,
|
|
53
|
-
},
|
|
54
|
-
addBtn: {
|
|
55
|
-
width: 34,
|
|
56
|
-
height: 34,
|
|
57
|
-
borderRadius: 17,
|
|
58
|
-
backgroundColor: tokens.colors.primary,
|
|
59
|
-
alignItems: "center",
|
|
60
|
-
justifyContent: "center",
|
|
61
|
-
},
|
|
62
|
-
emptyBox: {
|
|
63
|
-
alignItems: "center",
|
|
64
|
-
paddingTop: tokens.spacing.xl,
|
|
65
|
-
gap: tokens.spacing.sm,
|
|
66
|
-
},
|
|
67
|
-
item: {
|
|
68
|
-
flexDirection: "row",
|
|
69
|
-
alignItems: "center",
|
|
70
|
-
backgroundColor: tokens.colors.surface,
|
|
71
|
-
borderRadius: tokens.borders.radius.md,
|
|
72
|
-
marginHorizontal: tokens.spacing.md,
|
|
73
|
-
marginBottom: tokens.spacing.sm,
|
|
74
|
-
borderWidth: 1,
|
|
75
|
-
borderColor: tokens.colors.border,
|
|
76
|
-
overflow: "hidden",
|
|
77
|
-
},
|
|
78
|
-
itemActive: {
|
|
79
|
-
borderColor: tokens.colors.primary,
|
|
80
|
-
backgroundColor: tokens.colors.primaryContainer,
|
|
81
|
-
},
|
|
82
|
-
itemBar: { width: 4, alignSelf: "stretch", backgroundColor: tokens.colors.surfaceVariant },
|
|
83
|
-
itemBarActive: { backgroundColor: tokens.colors.primary },
|
|
84
|
-
itemContent: { flex: 1, padding: tokens.spacing.sm },
|
|
85
|
-
itemTimeRow: { flexDirection: "row", alignItems: "center", gap: tokens.spacing.xs, marginBottom: 2 },
|
|
86
|
-
itemEditBtn: {
|
|
87
|
-
width: 40,
|
|
88
|
-
height: 40,
|
|
89
|
-
alignItems: "center",
|
|
90
|
-
justifyContent: "center",
|
|
91
|
-
},
|
|
92
|
-
// Modal
|
|
93
|
-
overlay: {
|
|
94
|
-
flex: 1,
|
|
95
|
-
backgroundColor: "rgba(0,0,0,0.5)",
|
|
96
|
-
justifyContent: "flex-end",
|
|
97
|
-
},
|
|
98
|
-
modal: {
|
|
99
|
-
backgroundColor: tokens.colors.surface,
|
|
100
|
-
borderTopLeftRadius: tokens.borders.radius.xl,
|
|
101
|
-
borderTopRightRadius: tokens.borders.radius.xl,
|
|
102
|
-
paddingHorizontal: tokens.spacing.md,
|
|
103
|
-
paddingTop: tokens.spacing.md,
|
|
104
|
-
paddingBottom: tokens.spacing.xl,
|
|
105
|
-
maxHeight: "90%",
|
|
106
|
-
},
|
|
107
|
-
handle: {
|
|
108
|
-
width: 36,
|
|
109
|
-
height: 4,
|
|
110
|
-
borderRadius: 2,
|
|
111
|
-
backgroundColor: tokens.colors.border,
|
|
112
|
-
alignSelf: "center",
|
|
113
|
-
marginBottom: tokens.spacing.md,
|
|
114
|
-
},
|
|
115
|
-
textInput: {
|
|
116
|
-
backgroundColor: tokens.colors.surfaceVariant,
|
|
117
|
-
borderRadius: tokens.borders.radius.md,
|
|
118
|
-
padding: tokens.spacing.md,
|
|
119
|
-
color: tokens.colors.textPrimary,
|
|
120
|
-
fontSize: 16,
|
|
121
|
-
minHeight: 80,
|
|
122
|
-
textAlignVertical: "top",
|
|
123
|
-
marginBottom: tokens.spacing.md,
|
|
124
|
-
borderWidth: 1,
|
|
125
|
-
borderColor: tokens.colors.border,
|
|
126
|
-
},
|
|
127
|
-
timeRow: {
|
|
128
|
-
flexDirection: "row",
|
|
129
|
-
gap: tokens.spacing.md,
|
|
130
|
-
marginBottom: tokens.spacing.md,
|
|
131
|
-
},
|
|
132
|
-
actionRow: {
|
|
133
|
-
flexDirection: "row",
|
|
134
|
-
gap: tokens.spacing.md,
|
|
135
|
-
marginTop: tokens.spacing.md,
|
|
136
|
-
},
|
|
137
|
-
cancelBtn: {
|
|
138
|
-
flex: 1,
|
|
139
|
-
paddingVertical: tokens.spacing.md,
|
|
140
|
-
borderRadius: tokens.borders.radius.md,
|
|
141
|
-
backgroundColor: tokens.colors.surfaceVariant,
|
|
142
|
-
alignItems: "center",
|
|
143
|
-
},
|
|
144
|
-
saveBtn: {
|
|
145
|
-
flex: 1,
|
|
146
|
-
paddingVertical: tokens.spacing.md,
|
|
147
|
-
borderRadius: tokens.borders.radius.md,
|
|
148
|
-
backgroundColor: tokens.colors.primary,
|
|
149
|
-
alignItems: "center",
|
|
150
|
-
},
|
|
151
|
-
saveBtnDisabled: { opacity: 0.4 },
|
|
152
|
-
}), [tokens]);
|
|
37
|
+
const form = useSubtitleForm();
|
|
153
38
|
|
|
154
39
|
const activeId = useMemo(() => {
|
|
155
40
|
return subtitles.find((s) => currentTime >= s.startTime && currentTime <= s.endTime)?.id ?? null;
|
|
156
41
|
}, [subtitles, currentTime]);
|
|
157
42
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}, [currentTime]);
|
|
166
|
-
|
|
167
|
-
const openEdit = useCallback((sub: Subtitle) => {
|
|
168
|
-
setEditing(sub);
|
|
169
|
-
setText(sub.text);
|
|
170
|
-
setStartTime(sub.startTime);
|
|
171
|
-
setEndTime(sub.endTime);
|
|
172
|
-
setStyle({ ...sub.style });
|
|
173
|
-
setShowModal(true);
|
|
174
|
-
}, []);
|
|
175
|
-
|
|
176
|
-
const handleSave = useCallback(() => {
|
|
177
|
-
if (!text.trim()) return;
|
|
178
|
-
const resolvedEnd = Math.max(endTime, startTime + 0.5);
|
|
179
|
-
if (editing) {
|
|
180
|
-
onUpdate(editing.id, { text: text.trim(), startTime, endTime: resolvedEnd, style });
|
|
181
|
-
} else {
|
|
182
|
-
onAdd(text.trim(), startTime, resolvedEnd, style);
|
|
183
|
-
}
|
|
184
|
-
setShowModal(false);
|
|
185
|
-
}, [text, editing, startTime, endTime, style, onAdd, onUpdate]);
|
|
43
|
+
const emptyStyles = {
|
|
44
|
+
emptyBox: {
|
|
45
|
+
alignItems: "center" as const,
|
|
46
|
+
paddingTop: tokens.spacing.xl,
|
|
47
|
+
gap: tokens.spacing.sm,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
186
50
|
|
|
187
51
|
return (
|
|
188
52
|
<View style={{ flex: 1 }}>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
<TouchableOpacity style={styles.addBtn} onPress={openAdd} accessibilityRole="button">
|
|
195
|
-
<AtomicIcon name="add" size="sm" color="onPrimary" />
|
|
196
|
-
</TouchableOpacity>
|
|
197
|
-
</View>
|
|
53
|
+
<SubtitleListHeader
|
|
54
|
+
count={subtitles.length}
|
|
55
|
+
onAdd={() => form.openAdd(currentTime)}
|
|
56
|
+
title={t("subtitle.panel.title") || "Subtitles"}
|
|
57
|
+
/>
|
|
198
58
|
|
|
199
|
-
{/* List */}
|
|
200
59
|
<ScrollView contentContainerStyle={{ paddingVertical: tokens.spacing.sm }}>
|
|
201
60
|
{subtitles.length === 0 ? (
|
|
202
|
-
<View style={
|
|
61
|
+
<View style={emptyStyles.emptyBox}>
|
|
203
62
|
<AtomicIcon name="video" size="lg" color="textSecondary" />
|
|
204
|
-
<AtomicText color="textSecondary">
|
|
63
|
+
<AtomicText color="textSecondary">
|
|
64
|
+
{t("subtitle.panel.empty") || "No subtitles yet"}
|
|
65
|
+
</AtomicText>
|
|
205
66
|
<AtomicText type="labelSmall" color="textTertiary">
|
|
206
67
|
{t("subtitle.panel.emptyHint") || "Tap + to add a subtitle"}
|
|
207
68
|
</AtomicText>
|
|
208
69
|
</View>
|
|
209
70
|
) : (
|
|
210
|
-
subtitles.map((
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
<View style={[styles.itemBar, isActive && styles.itemBarActive]} />
|
|
220
|
-
<View style={styles.itemContent}>
|
|
221
|
-
<View style={styles.itemTimeRow}>
|
|
222
|
-
<AtomicText type="labelSmall" color="textSecondary">
|
|
223
|
-
{formatTimeDetailed(sub.startTime)}
|
|
224
|
-
</AtomicText>
|
|
225
|
-
<AtomicText type="labelSmall" color="textTertiary">→</AtomicText>
|
|
226
|
-
<AtomicText type="labelSmall" color="textSecondary">
|
|
227
|
-
{formatTimeDetailed(sub.endTime)}
|
|
228
|
-
</AtomicText>
|
|
229
|
-
</View>
|
|
230
|
-
<AtomicText color="textPrimary" numberOfLines={2}>
|
|
231
|
-
{sub.text}
|
|
232
|
-
</AtomicText>
|
|
233
|
-
</View>
|
|
234
|
-
<TouchableOpacity
|
|
235
|
-
style={styles.itemEditBtn}
|
|
236
|
-
onPress={() => openEdit(sub)}
|
|
237
|
-
accessibilityRole="button"
|
|
238
|
-
>
|
|
239
|
-
<AtomicIcon name="edit" size="sm" color="textSecondary" />
|
|
240
|
-
</TouchableOpacity>
|
|
241
|
-
</TouchableOpacity>
|
|
242
|
-
);
|
|
243
|
-
})
|
|
71
|
+
subtitles.map((subtitle) => (
|
|
72
|
+
<SubtitleListItem
|
|
73
|
+
key={subtitle.id}
|
|
74
|
+
subtitle={subtitle}
|
|
75
|
+
isActive={subtitle.id === activeId}
|
|
76
|
+
onEdit={form.openEdit}
|
|
77
|
+
onSeek={onSeek}
|
|
78
|
+
/>
|
|
79
|
+
))
|
|
244
80
|
)}
|
|
245
81
|
</ScrollView>
|
|
246
82
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
placeholder={t("subtitle.modal.placeholder") || "Enter subtitle text…"}
|
|
262
|
-
placeholderTextColor={tokens.colors.textSecondary}
|
|
263
|
-
multiline
|
|
264
|
-
numberOfLines={3}
|
|
265
|
-
/>
|
|
266
|
-
|
|
267
|
-
<View style={styles.timeRow}>
|
|
268
|
-
<SubtitleTimeInput
|
|
269
|
-
label={t("subtitle.modal.startTime") || "Start"}
|
|
270
|
-
value={startTime}
|
|
271
|
-
onChange={setStartTime}
|
|
272
|
-
/>
|
|
273
|
-
<SubtitleTimeInput
|
|
274
|
-
label={t("subtitle.modal.endTime") || "End"}
|
|
275
|
-
value={endTime}
|
|
276
|
-
onChange={setEndTime}
|
|
277
|
-
/>
|
|
278
|
-
</View>
|
|
279
|
-
|
|
280
|
-
<SubtitleStylePicker style={style} previewText={text} onChange={setStyle} t={t} />
|
|
281
|
-
|
|
282
|
-
<View style={styles.actionRow}>
|
|
283
|
-
<TouchableOpacity style={styles.cancelBtn} onPress={() => setShowModal(false)} accessibilityRole="button">
|
|
284
|
-
<AtomicText fontWeight="semibold" color="textSecondary">
|
|
285
|
-
{t("common.cancel") || "Cancel"}
|
|
286
|
-
</AtomicText>
|
|
287
|
-
</TouchableOpacity>
|
|
288
|
-
<TouchableOpacity
|
|
289
|
-
style={[styles.saveBtn, !text.trim() && styles.saveBtnDisabled]}
|
|
290
|
-
onPress={handleSave}
|
|
291
|
-
disabled={!text.trim()}
|
|
292
|
-
accessibilityRole="button"
|
|
293
|
-
>
|
|
294
|
-
<AtomicText fontWeight="semibold" color="onPrimary">
|
|
295
|
-
{t("common.save") || "Save"}
|
|
296
|
-
</AtomicText>
|
|
297
|
-
</TouchableOpacity>
|
|
298
|
-
</View>
|
|
299
|
-
</ScrollView>
|
|
300
|
-
</TouchableOpacity>
|
|
301
|
-
</TouchableOpacity>
|
|
302
|
-
</Modal>
|
|
83
|
+
<SubtitleModal
|
|
84
|
+
visible={form.showModal}
|
|
85
|
+
editing={!!form.editing}
|
|
86
|
+
text={form.text}
|
|
87
|
+
startTime={form.startTime}
|
|
88
|
+
endTime={form.endTime}
|
|
89
|
+
style={form.style}
|
|
90
|
+
onChangeText={form.setText}
|
|
91
|
+
onChangeStartTime={form.setStartTime}
|
|
92
|
+
onChangeEndTime={form.setEndTime}
|
|
93
|
+
onChangeStyle={form.setStyle}
|
|
94
|
+
onSave={() => form.save(onAdd, onUpdate)}
|
|
95
|
+
onCancel={form.close}
|
|
96
|
+
/>
|
|
303
97
|
</View>
|
|
304
98
|
);
|
|
305
99
|
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CollageCanvas Component
|
|
3
|
+
* Main canvas rendering for collage editor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, TouchableOpacity } from "react-native";
|
|
8
|
+
import { Image } from "expo-image";
|
|
9
|
+
import { AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
10
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
11
|
+
import type { CollageLayout } from "../../../infrastructure/constants/collage.constants";
|
|
12
|
+
|
|
13
|
+
interface CollageCanvasProps {
|
|
14
|
+
layout: CollageLayout;
|
|
15
|
+
images: (string | null)[];
|
|
16
|
+
spacing: number;
|
|
17
|
+
borderRadius: number;
|
|
18
|
+
onCellPress: (index: number) => void;
|
|
19
|
+
size: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const CollageCanvas: React.FC<CollageCanvasProps> = ({
|
|
23
|
+
layout,
|
|
24
|
+
images,
|
|
25
|
+
spacing,
|
|
26
|
+
borderRadius,
|
|
27
|
+
onCellPress,
|
|
28
|
+
size,
|
|
29
|
+
}) => {
|
|
30
|
+
const tokens = useAppDesignTokens();
|
|
31
|
+
|
|
32
|
+
const styles = {
|
|
33
|
+
canvas: {
|
|
34
|
+
width: size,
|
|
35
|
+
height: size,
|
|
36
|
+
alignSelf: "center" as const,
|
|
37
|
+
position: "relative" as const,
|
|
38
|
+
backgroundColor: tokens.colors.surface,
|
|
39
|
+
borderRadius: tokens.borders.radius.md,
|
|
40
|
+
overflow: "hidden" as const,
|
|
41
|
+
},
|
|
42
|
+
cell: {
|
|
43
|
+
position: "absolute" as const,
|
|
44
|
+
overflow: "hidden" as const,
|
|
45
|
+
},
|
|
46
|
+
cellImage: {
|
|
47
|
+
width: "100%" as const,
|
|
48
|
+
height: "100%" as const,
|
|
49
|
+
},
|
|
50
|
+
cellEmpty: {
|
|
51
|
+
flex: 1,
|
|
52
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
53
|
+
alignItems: "center" as const,
|
|
54
|
+
justifyContent: "center" as const,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<View style={styles.canvas}>
|
|
60
|
+
{layout.grid.map((cell, index) => {
|
|
61
|
+
const [cx, cy, cw, ch] = cell;
|
|
62
|
+
const cellStyle = {
|
|
63
|
+
left: cx * size + spacing,
|
|
64
|
+
top: cy * size + spacing,
|
|
65
|
+
width: cw * size - spacing * 2,
|
|
66
|
+
height: ch * size - spacing * 2,
|
|
67
|
+
borderRadius,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<TouchableOpacity
|
|
72
|
+
key={index}
|
|
73
|
+
style={[styles.cell, cellStyle]}
|
|
74
|
+
onPress={() => onCellPress(index)}
|
|
75
|
+
accessibilityLabel={`Cell ${index + 1}`}
|
|
76
|
+
accessibilityRole="button"
|
|
77
|
+
>
|
|
78
|
+
{images[index] ? (
|
|
79
|
+
<Image
|
|
80
|
+
source={{ uri: images[index]! }}
|
|
81
|
+
style={[styles.cellImage, { borderRadius }]}
|
|
82
|
+
contentFit="cover"
|
|
83
|
+
/>
|
|
84
|
+
) : (
|
|
85
|
+
<View style={styles.cellEmpty}>
|
|
86
|
+
<AtomicIcon name="add" size="md" color="textSecondary" />
|
|
87
|
+
</View>
|
|
88
|
+
)}
|
|
89
|
+
</TouchableOpacity>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CollageControls Component
|
|
3
|
+
* Controls for spacing and border radius in collage editor
|
|
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 CollageControlsProps {
|
|
12
|
+
spacing: number;
|
|
13
|
+
borderRadius: number;
|
|
14
|
+
onSpacingChange: (value: number) => void;
|
|
15
|
+
onBorderRadiusChange: (value: number) => void;
|
|
16
|
+
t: (key: string) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CollageControls: React.FC<CollageControlsProps> = ({
|
|
20
|
+
spacing,
|
|
21
|
+
borderRadius,
|
|
22
|
+
onSpacingChange,
|
|
23
|
+
onBorderRadiusChange,
|
|
24
|
+
t,
|
|
25
|
+
}) => {
|
|
26
|
+
const tokens = useAppDesignTokens();
|
|
27
|
+
|
|
28
|
+
const styles = {
|
|
29
|
+
controls: {
|
|
30
|
+
paddingHorizontal: tokens.spacing.md,
|
|
31
|
+
paddingTop: tokens.spacing.md,
|
|
32
|
+
gap: tokens.spacing.sm,
|
|
33
|
+
},
|
|
34
|
+
controlRow: {
|
|
35
|
+
flexDirection: "row" as const,
|
|
36
|
+
alignItems: "center" as const,
|
|
37
|
+
justifyContent: "space-between" as const,
|
|
38
|
+
},
|
|
39
|
+
stepper: {
|
|
40
|
+
flexDirection: "row" as const,
|
|
41
|
+
alignItems: "center" as const,
|
|
42
|
+
gap: tokens.spacing.sm,
|
|
43
|
+
},
|
|
44
|
+
stepBtn: {
|
|
45
|
+
width: 32,
|
|
46
|
+
height: 32,
|
|
47
|
+
borderRadius: 16,
|
|
48
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
49
|
+
alignItems: "center" as const,
|
|
50
|
+
justifyContent: "center" as const,
|
|
51
|
+
},
|
|
52
|
+
stepValue: {
|
|
53
|
+
minWidth: 28,
|
|
54
|
+
textAlign: "center" as const,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<View style={styles.controls}>
|
|
60
|
+
<View style={styles.controlRow}>
|
|
61
|
+
<AtomicText type="labelSmall" color="textSecondary">
|
|
62
|
+
{t("editor.collage.spacing") || "Spacing"}
|
|
63
|
+
</AtomicText>
|
|
64
|
+
<View style={styles.stepper}>
|
|
65
|
+
<TouchableOpacity
|
|
66
|
+
style={styles.stepBtn}
|
|
67
|
+
onPress={() => onSpacingChange(Math.max(0, spacing - 2))}
|
|
68
|
+
accessibilityLabel="Decrease spacing"
|
|
69
|
+
accessibilityRole="button"
|
|
70
|
+
>
|
|
71
|
+
<AtomicIcon name="chevron-back" size="sm" color="textSecondary" />
|
|
72
|
+
</TouchableOpacity>
|
|
73
|
+
<AtomicText fontWeight="bold" style={styles.stepValue}>
|
|
74
|
+
{spacing}
|
|
75
|
+
</AtomicText>
|
|
76
|
+
<TouchableOpacity
|
|
77
|
+
style={styles.stepBtn}
|
|
78
|
+
onPress={() => onSpacingChange(Math.min(16, spacing + 2))}
|
|
79
|
+
accessibilityLabel="Increase spacing"
|
|
80
|
+
accessibilityRole="button"
|
|
81
|
+
>
|
|
82
|
+
<AtomicIcon name="chevron-forward" size="sm" color="textSecondary" />
|
|
83
|
+
</TouchableOpacity>
|
|
84
|
+
</View>
|
|
85
|
+
</View>
|
|
86
|
+
|
|
87
|
+
<View style={styles.controlRow}>
|
|
88
|
+
<AtomicText type="labelSmall" color="textSecondary">
|
|
89
|
+
{t("editor.collage.borderRadius") || "Corner Radius"}
|
|
90
|
+
</AtomicText>
|
|
91
|
+
<View style={styles.stepper}>
|
|
92
|
+
<TouchableOpacity
|
|
93
|
+
style={styles.stepBtn}
|
|
94
|
+
onPress={() => onBorderRadiusChange(Math.max(0, borderRadius - 4))}
|
|
95
|
+
accessibilityLabel="Decrease border radius"
|
|
96
|
+
accessibilityRole="button"
|
|
97
|
+
>
|
|
98
|
+
<AtomicIcon name="chevron-back" size="sm" color="textSecondary" />
|
|
99
|
+
</TouchableOpacity>
|
|
100
|
+
<AtomicText fontWeight="bold" style={styles.stepValue}>
|
|
101
|
+
{borderRadius}
|
|
102
|
+
</AtomicText>
|
|
103
|
+
<TouchableOpacity
|
|
104
|
+
style={styles.stepBtn}
|
|
105
|
+
onPress={() => onBorderRadiusChange(Math.min(32, borderRadius + 4))}
|
|
106
|
+
accessibilityLabel="Increase border radius"
|
|
107
|
+
accessibilityRole="button"
|
|
108
|
+
>
|
|
109
|
+
<AtomicIcon name="chevron-forward" size="sm" color="textSecondary" />
|
|
110
|
+
</TouchableOpacity>
|
|
111
|
+
</View>
|
|
112
|
+
</View>
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CollageLayoutSelector Component
|
|
3
|
+
* Layout picker for collage editor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { View, ScrollView, TouchableOpacity } from "react-native";
|
|
8
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
9
|
+
import { COLLAGE_LAYOUTS } from "../../../infrastructure/constants/collage.constants";
|
|
10
|
+
import type { CollageLayout } from "../../../infrastructure/constants/collage.constants";
|
|
11
|
+
|
|
12
|
+
interface CollageLayoutSelectorProps {
|
|
13
|
+
selected: CollageLayout;
|
|
14
|
+
onSelect: (layout: CollageLayout) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const CollageLayoutSelector: React.FC<CollageLayoutSelectorProps> = ({
|
|
18
|
+
selected,
|
|
19
|
+
onSelect,
|
|
20
|
+
}) => {
|
|
21
|
+
const tokens = useAppDesignTokens();
|
|
22
|
+
|
|
23
|
+
const styles = {
|
|
24
|
+
layoutSection: {
|
|
25
|
+
paddingTop: tokens.spacing.sm,
|
|
26
|
+
},
|
|
27
|
+
layoutScroll: {
|
|
28
|
+
paddingHorizontal: tokens.spacing.md,
|
|
29
|
+
gap: tokens.spacing.sm,
|
|
30
|
+
},
|
|
31
|
+
layoutCard: {
|
|
32
|
+
width: 64,
|
|
33
|
+
alignItems: "center" as const,
|
|
34
|
+
gap: tokens.spacing.xs,
|
|
35
|
+
},
|
|
36
|
+
layoutPreview: {
|
|
37
|
+
width: 52,
|
|
38
|
+
height: 52,
|
|
39
|
+
borderRadius: tokens.borders.radius.sm,
|
|
40
|
+
overflow: "hidden" as const,
|
|
41
|
+
position: "relative" as const,
|
|
42
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
43
|
+
borderWidth: 2,
|
|
44
|
+
borderColor: "transparent",
|
|
45
|
+
},
|
|
46
|
+
layoutPreviewActive: {
|
|
47
|
+
borderColor: tokens.colors.primary,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<View style={styles.layoutSection}>
|
|
53
|
+
<ScrollView
|
|
54
|
+
horizontal
|
|
55
|
+
showsHorizontalScrollIndicator={false}
|
|
56
|
+
contentContainerStyle={styles.layoutScroll}
|
|
57
|
+
>
|
|
58
|
+
{COLLAGE_LAYOUTS.map((layout) => (
|
|
59
|
+
<TouchableOpacity
|
|
60
|
+
key={layout.id}
|
|
61
|
+
style={styles.layoutCard}
|
|
62
|
+
onPress={() => onSelect(layout)}
|
|
63
|
+
accessibilityLabel={`Layout ${layout.id}`}
|
|
64
|
+
accessibilityRole="button"
|
|
65
|
+
accessibilityState={{ selected: selected.id === layout.id }}
|
|
66
|
+
>
|
|
67
|
+
<View
|
|
68
|
+
style={[
|
|
69
|
+
styles.layoutPreview,
|
|
70
|
+
selected.id === layout.id && styles.layoutPreviewActive,
|
|
71
|
+
]}
|
|
72
|
+
>
|
|
73
|
+
{layout.grid.map((cell, index) => {
|
|
74
|
+
const [cx, cy, cw, ch] = cell;
|
|
75
|
+
return (
|
|
76
|
+
<View
|
|
77
|
+
key={index}
|
|
78
|
+
style={{
|
|
79
|
+
position: "absolute",
|
|
80
|
+
left: `${cx * 100}%`,
|
|
81
|
+
top: `${cy * 100}%`,
|
|
82
|
+
width: `${cw * 100}%`,
|
|
83
|
+
height: `${ch * 100}%`,
|
|
84
|
+
borderWidth: 1,
|
|
85
|
+
borderColor: tokens.colors.border,
|
|
86
|
+
backgroundColor:
|
|
87
|
+
selected.id === layout.id
|
|
88
|
+
? tokens.colors.primaryContainer
|
|
89
|
+
: "transparent",
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</View>
|
|
95
|
+
</TouchableOpacity>
|
|
96
|
+
))}
|
|
97
|
+
</ScrollView>
|
|
98
|
+
</View>
|
|
99
|
+
);
|
|
100
|
+
};
|