@umituz/react-native-video-editor 1.1.38 → 1.1.40

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-video-editor",
3
- "version": "1.1.38",
3
+ "version": "1.1.40",
4
4
  "description": "Professional video editor with layer-based timeline, text/image/shape/audio/animation layers, and export functionality",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,210 @@
1
+ /**
2
+ * VideoEditor Component
3
+ * Self-contained full-screen video editor.
4
+ * Mirrors PhotoEditor API: accepts videoUri, onClose, onSave.
5
+ */
6
+
7
+ import React, { useState, useMemo, useRef, useCallback } from "react";
8
+ import { View, TouchableOpacity, StyleSheet } from "react-native";
9
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
10
+ import { BottomSheetModal } from "@umituz/react-native-design-system/molecules";
11
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
12
+ import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
13
+
14
+ import { VideoPlayer } from "./player/presentation/components/VideoPlayer";
15
+ import { VideoFilterPicker } from "./presentation/components/VideoFilterPicker";
16
+ import { SpeedControlPanel } from "./presentation/components/SpeedControlPanel";
17
+ import { FILTER_PRESETS, DEFAULT_FILTER } from "./infrastructure/constants/filter.constants";
18
+ import { DEFAULT_PLAYBACK_RATE } from "./infrastructure/constants/speed.constants";
19
+ import type { FilterPreset } from "./domain/entities/video-project.types";
20
+
21
+ export interface VideoEditorProps {
22
+ videoUri: string;
23
+ onClose: () => void;
24
+ onSave?: (uri: string, filter: FilterPreset, playbackRate: number) => void;
25
+ title?: string;
26
+ t: (key: string) => string;
27
+ }
28
+
29
+ type ActiveTool = "filters" | "speed" | null;
30
+
31
+ export const VideoEditor: React.FC<VideoEditorProps> = ({
32
+ videoUri,
33
+ onClose,
34
+ onSave,
35
+ title,
36
+ t,
37
+ }) => {
38
+ const tokens = useAppDesignTokens();
39
+ const insets = useSafeAreaInsets();
40
+
41
+ const [activeFilter, setActiveFilter] = useState<FilterPreset>(DEFAULT_FILTER);
42
+ const [playbackRate, setPlaybackRate] = useState(DEFAULT_PLAYBACK_RATE);
43
+ const [activeTool, setActiveTool] = useState<ActiveTool>(null);
44
+
45
+ const filterSheetRef = useRef<{ present: () => void; dismiss: () => void }>(null);
46
+ const speedSheetRef = useRef<{ present: () => void; dismiss: () => void }>(null);
47
+
48
+ const handleToggleTool = useCallback((tool: Exclude<ActiveTool, null>) => {
49
+ if (activeTool === tool) {
50
+ setActiveTool(null);
51
+ if (tool === "filters") filterSheetRef.current?.dismiss();
52
+ else speedSheetRef.current?.dismiss();
53
+ } else {
54
+ setActiveTool(tool);
55
+ if (tool === "filters") filterSheetRef.current?.present();
56
+ else speedSheetRef.current?.present();
57
+ }
58
+ }, [activeTool]);
59
+
60
+ const handleSave = useCallback(() => {
61
+ onSave?.(videoUri, activeFilter, playbackRate);
62
+ onClose();
63
+ }, [onSave, onClose, videoUri, activeFilter, playbackRate]);
64
+
65
+ const styles = useMemo(() => StyleSheet.create({
66
+ container: {
67
+ flex: 1,
68
+ backgroundColor: tokens.colors.backgroundPrimary,
69
+ },
70
+ header: {
71
+ flexDirection: "row",
72
+ alignItems: "center",
73
+ justifyContent: "space-between",
74
+ paddingTop: insets.top + tokens.spacing.sm,
75
+ paddingBottom: tokens.spacing.sm,
76
+ paddingHorizontal: tokens.spacing.md,
77
+ backgroundColor: tokens.colors.surface,
78
+ },
79
+ headerTitle: {
80
+ flex: 1,
81
+ textAlign: "center",
82
+ },
83
+ headerBtn: {
84
+ width: 40,
85
+ height: 40,
86
+ alignItems: "center",
87
+ justifyContent: "center",
88
+ },
89
+ videoArea: {
90
+ flex: 1,
91
+ justifyContent: "center",
92
+ alignItems: "center",
93
+ backgroundColor: tokens.colors.backgroundPrimary,
94
+ },
95
+ toolbar: {
96
+ flexDirection: "row",
97
+ justifyContent: "space-around",
98
+ paddingVertical: tokens.spacing.md,
99
+ paddingHorizontal: tokens.spacing.md,
100
+ paddingBottom: insets.bottom + tokens.spacing.md,
101
+ backgroundColor: tokens.colors.surface,
102
+ borderTopWidth: 1,
103
+ borderTopColor: tokens.colors.border,
104
+ },
105
+ toolBtn: {
106
+ alignItems: "center",
107
+ gap: tokens.spacing.xs,
108
+ paddingHorizontal: tokens.spacing.lg,
109
+ },
110
+ toolBtnActive: {
111
+ opacity: 1,
112
+ },
113
+ }), [tokens, insets]);
114
+
115
+ const TOOLS: { id: Exclude<ActiveTool, null>; icon: string; labelKey: string }[] = [
116
+ { id: "filters", icon: "sparkles", labelKey: "editor.tools.filters" },
117
+ { id: "speed", icon: "flash", labelKey: "editor.tools.speed" },
118
+ ];
119
+
120
+ return (
121
+ <View style={styles.container}>
122
+ {/* Header */}
123
+ <View style={styles.header}>
124
+ <TouchableOpacity
125
+ style={styles.headerBtn}
126
+ onPress={onClose}
127
+ accessibilityLabel="Close"
128
+ accessibilityRole="button"
129
+ >
130
+ <AtomicIcon name="close" size="md" color="textPrimary" />
131
+ </TouchableOpacity>
132
+
133
+ <AtomicText type="headlineSmall" style={styles.headerTitle}>
134
+ {title || t("editor.video.title") || "Edit Video"}
135
+ </AtomicText>
136
+
137
+ <TouchableOpacity
138
+ style={styles.headerBtn}
139
+ onPress={handleSave}
140
+ accessibilityLabel="Save"
141
+ accessibilityRole="button"
142
+ >
143
+ <AtomicText fontWeight="bold" color="primary">
144
+ {t("common.save") || "Save"}
145
+ </AtomicText>
146
+ </TouchableOpacity>
147
+ </View>
148
+
149
+ {/* Video Preview */}
150
+ <View style={styles.videoArea}>
151
+ <VideoPlayer
152
+ source={videoUri}
153
+ autoPlay
154
+ loop
155
+ nativeControls={false}
156
+ contentFit="contain"
157
+ playbackRate={playbackRate}
158
+ filterOverlay={activeFilter.id !== "none" ? activeFilter : undefined}
159
+ />
160
+ </View>
161
+
162
+ {/* Toolbar */}
163
+ <View style={styles.toolbar}>
164
+ {TOOLS.map((tool) => {
165
+ const isActive = activeTool === tool.id;
166
+ return (
167
+ <TouchableOpacity
168
+ key={tool.id}
169
+ style={styles.toolBtn}
170
+ onPress={() => handleToggleTool(tool.id)}
171
+ accessibilityLabel={t(tool.labelKey) || tool.id}
172
+ accessibilityRole="button"
173
+ accessibilityState={{ selected: isActive }}
174
+ >
175
+ <AtomicIcon
176
+ name={tool.icon}
177
+ size="md"
178
+ color={isActive ? "primary" : "textSecondary"}
179
+ />
180
+ <AtomicText
181
+ type="labelSmall"
182
+ color={isActive ? "primary" : "textSecondary"}
183
+ >
184
+ {t(tool.labelKey) || tool.id}
185
+ </AtomicText>
186
+ </TouchableOpacity>
187
+ );
188
+ })}
189
+ </View>
190
+
191
+ {/* Filter Bottom Sheet */}
192
+ <BottomSheetModal ref={filterSheetRef} snapPoints={["40%"]}>
193
+ <VideoFilterPicker
194
+ activeFilter={activeFilter}
195
+ onSelectFilter={setActiveFilter}
196
+ t={t}
197
+ />
198
+ </BottomSheetModal>
199
+
200
+ {/* Speed Bottom Sheet */}
201
+ <BottomSheetModal ref={speedSheetRef} snapPoints={["30%"]}>
202
+ <SpeedControlPanel
203
+ playbackRate={playbackRate}
204
+ onChangeRate={setPlaybackRate}
205
+ t={t}
206
+ />
207
+ </BottomSheetModal>
208
+ </View>
209
+ );
210
+ };
package/src/index.ts CHANGED
@@ -60,6 +60,9 @@ export {
60
60
  // PRESENTATION LAYER - Components & Hooks
61
61
  // =============================================================================
62
62
 
63
+ export { VideoEditor } from "./VideoEditor";
64
+ export type { VideoEditorProps } from "./VideoEditor";
65
+
63
66
  export {
64
67
  EditorHeader,
65
68
  EditorPreviewArea,
@@ -76,6 +79,7 @@ export {
76
79
  ExportDialog,
77
80
  SpeedControlPanel,
78
81
  VideoFilterPicker,
82
+ CollageEditorCanvas,
79
83
  } from "./presentation/components";
80
84
 
81
85
  export { useEditorLayers } from "./presentation/hooks/useEditorLayers";
@@ -100,6 +104,8 @@ export { useLayerActions } from "./presentation/hooks/useLayerActions";
100
104
  export { useSceneActions } from "./presentation/hooks/useSceneActions";
101
105
  export { useMenuActions } from "./presentation/hooks/useMenuActions";
102
106
  export { useExportActions } from "./presentation/hooks/useExportActions";
107
+ export { useCollageEditor } from "./presentation/hooks/useCollageEditor";
108
+ export type { UseCollageEditorReturn } from "./presentation/hooks/useCollageEditor";
103
109
 
104
110
  // =============================================================================
105
111
  // VIDEO PLAYER MODULE
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Collage Layouts
3
+ * Grid definitions for collage editor
4
+ * Each cell: [x, y, width, height] as fractions of canvas (0–1)
5
+ */
6
+
7
+ export interface CollageLayout {
8
+ readonly id: string;
9
+ readonly count: number;
10
+ readonly grid: readonly [number, number, number, number][];
11
+ }
12
+
13
+ export const COLLAGE_LAYOUTS: CollageLayout[] = [
14
+ {
15
+ id: "2h",
16
+ count: 2,
17
+ grid: [
18
+ [0, 0, 0.5, 1],
19
+ [0.5, 0, 0.5, 1],
20
+ ],
21
+ },
22
+ {
23
+ id: "2v",
24
+ count: 2,
25
+ grid: [
26
+ [0, 0, 1, 0.5],
27
+ [0, 0.5, 1, 0.5],
28
+ ],
29
+ },
30
+ {
31
+ id: "3a",
32
+ count: 3,
33
+ grid: [
34
+ [0, 0, 0.5, 1],
35
+ [0.5, 0, 0.5, 0.5],
36
+ [0.5, 0.5, 0.5, 0.5],
37
+ ],
38
+ },
39
+ {
40
+ id: "3b",
41
+ count: 3,
42
+ grid: [
43
+ [0, 0, 1, 0.5],
44
+ [0, 0.5, 0.5, 0.5],
45
+ [0.5, 0.5, 0.5, 0.5],
46
+ ],
47
+ },
48
+ {
49
+ id: "4",
50
+ count: 4,
51
+ grid: [
52
+ [0, 0, 0.5, 0.5],
53
+ [0.5, 0, 0.5, 0.5],
54
+ [0, 0.5, 0.5, 0.5],
55
+ [0.5, 0.5, 0.5, 0.5],
56
+ ],
57
+ },
58
+ {
59
+ id: "6",
60
+ count: 6,
61
+ grid: [
62
+ [0, 0, 0.333, 0.5],
63
+ [0.333, 0, 0.334, 0.5],
64
+ [0.667, 0, 0.333, 0.5],
65
+ [0, 0.5, 0.333, 0.5],
66
+ [0.333, 0.5, 0.334, 0.5],
67
+ [0.667, 0.5, 0.333, 0.5],
68
+ ],
69
+ },
70
+ ];
71
+
72
+ export const DEFAULT_COLLAGE_LAYOUT = COLLAGE_LAYOUTS[0];
73
+ export const DEFAULT_COLLAGE_SPACING = 4;
74
+ export const DEFAULT_COLLAGE_BORDER_RADIUS = 8;
@@ -11,3 +11,4 @@ export * from "./image-layer.constants";
11
11
  export * from "./export.constants";
12
12
  export * from "./filter.constants";
13
13
  export * from "./speed.constants";
14
+ export * from "./collage.constants";
@@ -0,0 +1,285 @@
1
+ /**
2
+ * CollageEditorCanvas Component
3
+ * Collage layout canvas with image cells, layout picker, spacing and border radius controls
4
+ */
5
+
6
+ import React, { useMemo } from "react";
7
+ import {
8
+ View,
9
+ ScrollView,
10
+ TouchableOpacity,
11
+ StyleSheet,
12
+ Dimensions,
13
+ } from "react-native";
14
+ import { Image } from "expo-image";
15
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
16
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
17
+ import { COLLAGE_LAYOUTS } from "../../infrastructure/constants/collage.constants";
18
+ import type { CollageLayout } from "../../infrastructure/constants/collage.constants";
19
+
20
+ const SCREEN_WIDTH = Dimensions.get("window").width;
21
+
22
+ interface CollageEditorCanvasProps {
23
+ layout: CollageLayout;
24
+ images: (string | null)[];
25
+ spacing: number;
26
+ borderRadius: number;
27
+ onSelectLayout: (layout: CollageLayout) => void;
28
+ onCellPress: (index: number) => void;
29
+ onSpacingChange: (value: number) => void;
30
+ onBorderRadiusChange: (value: number) => void;
31
+ t: (key: string) => string;
32
+ canvasSize?: number;
33
+ }
34
+
35
+ export const CollageEditorCanvas: React.FC<CollageEditorCanvasProps> = ({
36
+ layout,
37
+ images,
38
+ spacing,
39
+ borderRadius,
40
+ onSelectLayout,
41
+ onCellPress,
42
+ onSpacingChange,
43
+ onBorderRadiusChange,
44
+ t,
45
+ canvasSize,
46
+ }) => {
47
+ const tokens = useAppDesignTokens();
48
+ const size = canvasSize ?? SCREEN_WIDTH - tokens.spacing.md * 2;
49
+
50
+ const styles = useMemo(
51
+ () =>
52
+ StyleSheet.create({
53
+ canvas: {
54
+ width: size,
55
+ height: size,
56
+ alignSelf: "center",
57
+ position: "relative",
58
+ backgroundColor: tokens.colors.surface,
59
+ borderRadius: tokens.borders.radius.md,
60
+ overflow: "hidden",
61
+ },
62
+ cell: {
63
+ position: "absolute",
64
+ overflow: "hidden",
65
+ },
66
+ cellImage: {
67
+ width: "100%",
68
+ height: "100%",
69
+ },
70
+ cellEmpty: {
71
+ flex: 1,
72
+ backgroundColor: tokens.colors.surfaceVariant,
73
+ alignItems: "center",
74
+ justifyContent: "center",
75
+ },
76
+ controls: {
77
+ paddingHorizontal: tokens.spacing.md,
78
+ paddingTop: tokens.spacing.md,
79
+ gap: tokens.spacing.sm,
80
+ },
81
+ controlRow: {
82
+ flexDirection: "row",
83
+ alignItems: "center",
84
+ justifyContent: "space-between",
85
+ },
86
+ stepper: {
87
+ flexDirection: "row",
88
+ alignItems: "center",
89
+ gap: tokens.spacing.sm,
90
+ },
91
+ stepBtn: {
92
+ width: 32,
93
+ height: 32,
94
+ borderRadius: 16,
95
+ backgroundColor: tokens.colors.surfaceVariant,
96
+ alignItems: "center",
97
+ justifyContent: "center",
98
+ },
99
+ stepValue: {
100
+ minWidth: 28,
101
+ textAlign: "center",
102
+ },
103
+ layoutSection: {
104
+ paddingTop: tokens.spacing.sm,
105
+ },
106
+ layoutScroll: {
107
+ paddingHorizontal: tokens.spacing.md,
108
+ gap: tokens.spacing.sm,
109
+ },
110
+ layoutCard: {
111
+ width: 64,
112
+ alignItems: "center",
113
+ gap: tokens.spacing.xs,
114
+ },
115
+ layoutPreview: {
116
+ width: 52,
117
+ height: 52,
118
+ borderRadius: tokens.borders.radius.sm,
119
+ overflow: "hidden",
120
+ position: "relative",
121
+ backgroundColor: tokens.colors.surfaceVariant,
122
+ borderWidth: 2,
123
+ borderColor: "transparent",
124
+ },
125
+ layoutPreviewActive: {
126
+ borderColor: tokens.colors.primary,
127
+ },
128
+ }),
129
+ [tokens, size],
130
+ );
131
+
132
+ return (
133
+ <View>
134
+ {/* Canvas */}
135
+ <View style={styles.canvas}>
136
+ {layout.grid.map((cell, index) => {
137
+ const [cx, cy, cw, ch] = cell;
138
+ const cellStyle = {
139
+ left: cx * size + spacing,
140
+ top: cy * size + spacing,
141
+ width: cw * size - spacing * 2,
142
+ height: ch * size - spacing * 2,
143
+ borderRadius,
144
+ };
145
+ return (
146
+ <TouchableOpacity
147
+ key={index}
148
+ style={[styles.cell, cellStyle]}
149
+ onPress={() => onCellPress(index)}
150
+ accessibilityLabel={`Cell ${index + 1}`}
151
+ accessibilityRole="button"
152
+ >
153
+ {images[index] ? (
154
+ <Image
155
+ source={{ uri: images[index]! }}
156
+ style={[styles.cellImage, { borderRadius }]}
157
+ contentFit="cover"
158
+ />
159
+ ) : (
160
+ <View style={styles.cellEmpty}>
161
+ <AtomicIcon name="add" size="md" color="textSecondary" />
162
+ </View>
163
+ )}
164
+ </TouchableOpacity>
165
+ );
166
+ })}
167
+ </View>
168
+
169
+ {/* Spacing + Border Radius */}
170
+ <View style={styles.controls}>
171
+ <View style={styles.controlRow}>
172
+ <AtomicText type="labelSmall" color="textSecondary">
173
+ {t("editor.collage.spacing") || "Spacing"}
174
+ </AtomicText>
175
+ <View style={styles.stepper}>
176
+ <TouchableOpacity
177
+ style={styles.stepBtn}
178
+ onPress={() => onSpacingChange(Math.max(0, spacing - 2))}
179
+ accessibilityLabel="Decrease spacing"
180
+ accessibilityRole="button"
181
+ >
182
+ <AtomicIcon name="chevron-back" size="sm" color="textSecondary" />
183
+ </TouchableOpacity>
184
+ <AtomicText fontWeight="bold" style={styles.stepValue}>
185
+ {spacing}
186
+ </AtomicText>
187
+ <TouchableOpacity
188
+ style={styles.stepBtn}
189
+ onPress={() => onSpacingChange(Math.min(16, spacing + 2))}
190
+ accessibilityLabel="Increase spacing"
191
+ accessibilityRole="button"
192
+ >
193
+ <AtomicIcon name="chevron-forward" size="sm" color="textSecondary" />
194
+ </TouchableOpacity>
195
+ </View>
196
+ </View>
197
+
198
+ <View style={styles.controlRow}>
199
+ <AtomicText type="labelSmall" color="textSecondary">
200
+ {t("editor.collage.corners") || "Corners"}
201
+ </AtomicText>
202
+ <View style={styles.stepper}>
203
+ <TouchableOpacity
204
+ style={styles.stepBtn}
205
+ onPress={() => onBorderRadiusChange(Math.max(0, borderRadius - 4))}
206
+ accessibilityLabel="Decrease corner radius"
207
+ accessibilityRole="button"
208
+ >
209
+ <AtomicIcon name="chevron-back" size="sm" color="textSecondary" />
210
+ </TouchableOpacity>
211
+ <AtomicText fontWeight="bold" style={styles.stepValue}>
212
+ {borderRadius}
213
+ </AtomicText>
214
+ <TouchableOpacity
215
+ style={styles.stepBtn}
216
+ onPress={() => onBorderRadiusChange(Math.min(24, borderRadius + 4))}
217
+ accessibilityLabel="Increase corner radius"
218
+ accessibilityRole="button"
219
+ >
220
+ <AtomicIcon name="chevron-forward" size="sm" color="textSecondary" />
221
+ </TouchableOpacity>
222
+ </View>
223
+ </View>
224
+ </View>
225
+
226
+ {/* Layout Picker */}
227
+ <View style={styles.layoutSection}>
228
+ <AtomicText
229
+ type="labelSmall"
230
+ color="textSecondary"
231
+ style={{ paddingHorizontal: tokens.spacing.md, marginBottom: tokens.spacing.xs }}
232
+ >
233
+ {t("editor.collage.layout") || "Layout"}
234
+ </AtomicText>
235
+ <ScrollView
236
+ horizontal
237
+ showsHorizontalScrollIndicator={false}
238
+ contentContainerStyle={styles.layoutScroll}
239
+ >
240
+ {COLLAGE_LAYOUTS.map((l) => {
241
+ const isActive = layout.id === l.id;
242
+ return (
243
+ <TouchableOpacity
244
+ key={l.id}
245
+ style={styles.layoutCard}
246
+ onPress={() => onSelectLayout(l)}
247
+ accessibilityLabel={`Layout ${l.count} cells`}
248
+ accessibilityRole="button"
249
+ accessibilityState={{ selected: isActive }}
250
+ >
251
+ <View style={[styles.layoutPreview, isActive && styles.layoutPreviewActive]}>
252
+ {l.grid.map((cell, i) => {
253
+ const [lx, ly, lw, lh] = cell;
254
+ return (
255
+ <View
256
+ key={i}
257
+ style={{
258
+ position: "absolute",
259
+ left: lx * 52 + 2,
260
+ top: ly * 52 + 2,
261
+ width: lw * 52 - 4,
262
+ height: lh * 52 - 4,
263
+ backgroundColor: isActive
264
+ ? tokens.colors.primary
265
+ : tokens.colors.surfaceVariant,
266
+ borderRadius: 2,
267
+ }}
268
+ />
269
+ );
270
+ })}
271
+ </View>
272
+ <AtomicText
273
+ type="labelSmall"
274
+ color={isActive ? "primary" : "textSecondary"}
275
+ >
276
+ {l.count}
277
+ </AtomicText>
278
+ </TouchableOpacity>
279
+ );
280
+ })}
281
+ </ScrollView>
282
+ </View>
283
+ </View>
284
+ );
285
+ };
@@ -17,3 +17,4 @@ export * from "./ImageLayerEditor";
17
17
  export * from "./ExportDialog";
18
18
  export * from "./SpeedControlPanel";
19
19
  export * from "./VideoFilterPicker";
20
+ export * from "./CollageEditorCanvas";
@@ -0,0 +1,73 @@
1
+ /**
2
+ * useCollageEditor Hook
3
+ * State management for collage editor
4
+ */
5
+
6
+ import { useState, useCallback } from "react";
7
+ import {
8
+ COLLAGE_LAYOUTS,
9
+ DEFAULT_COLLAGE_LAYOUT,
10
+ DEFAULT_COLLAGE_SPACING,
11
+ DEFAULT_COLLAGE_BORDER_RADIUS,
12
+ } from "../../infrastructure/constants/collage.constants";
13
+ import type { CollageLayout } from "../../infrastructure/constants/collage.constants";
14
+
15
+ export interface UseCollageEditorReturn {
16
+ layout: CollageLayout;
17
+ images: (string | null)[];
18
+ spacing: number;
19
+ borderRadius: number;
20
+ setLayout: (layout: CollageLayout) => void;
21
+ setImage: (index: number, uri: string) => void;
22
+ clearImage: (index: number) => void;
23
+ setSpacing: (value: number) => void;
24
+ setBorderRadius: (value: number) => void;
25
+ filledCount: number;
26
+ allLayouts: CollageLayout[];
27
+ }
28
+
29
+ export function useCollageEditor(): UseCollageEditorReturn {
30
+ const [layout, setLayoutState] = useState<CollageLayout>(DEFAULT_COLLAGE_LAYOUT);
31
+ const [images, setImages] = useState<(string | null)[]>(
32
+ new Array(DEFAULT_COLLAGE_LAYOUT.count).fill(null),
33
+ );
34
+ const [spacing, setSpacing] = useState(DEFAULT_COLLAGE_SPACING);
35
+ const [borderRadius, setBorderRadius] = useState(DEFAULT_COLLAGE_BORDER_RADIUS);
36
+
37
+ const setLayout = useCallback((newLayout: CollageLayout) => {
38
+ setLayoutState(newLayout);
39
+ setImages(new Array(newLayout.count).fill(null));
40
+ }, []);
41
+
42
+ const setImage = useCallback((index: number, uri: string) => {
43
+ setImages((prev) => {
44
+ const next = [...prev];
45
+ next[index] = uri;
46
+ return next;
47
+ });
48
+ }, []);
49
+
50
+ const clearImage = useCallback((index: number) => {
51
+ setImages((prev) => {
52
+ const next = [...prev];
53
+ next[index] = null;
54
+ return next;
55
+ });
56
+ }, []);
57
+
58
+ const filledCount = images.filter((img) => img !== null).length;
59
+
60
+ return {
61
+ layout,
62
+ images,
63
+ spacing,
64
+ borderRadius,
65
+ setLayout,
66
+ setImage,
67
+ clearImage,
68
+ setSpacing,
69
+ setBorderRadius,
70
+ filledCount,
71
+ allLayouts: COLLAGE_LAYOUTS,
72
+ };
73
+ }