@umituz/react-native-video-editor 1.1.37 → 1.1.39
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/domain/entities/video-project.types.ts +8 -0
- package/src/index.ts +6 -0
- package/src/infrastructure/constants/collage.constants.ts +74 -0
- package/src/infrastructure/constants/filter.constants.ts +26 -0
- package/src/infrastructure/constants/index.ts +3 -0
- package/src/infrastructure/constants/speed.constants.ts +22 -0
- package/src/player/presentation/components/VideoPlayer.tsx +12 -0
- package/src/player/presentation/hooks/useVideoPlayerControl.ts +12 -4
- package/src/player/types/index.ts +5 -0
- package/src/presentation/components/CollageEditorCanvas.tsx +285 -0
- package/src/presentation/components/EditorToolPanel.tsx +38 -20
- package/src/presentation/components/SpeedControlPanel.tsx +76 -0
- package/src/presentation/components/VideoFilterPicker.tsx +94 -0
- package/src/presentation/components/index.ts +3 -0
- package/src/presentation/hooks/useCollageEditor.ts +73 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-video-editor",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.39",
|
|
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",
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
export type AspectRatio = "16:9" | "9:16" | "1:1" | "4:5";
|
|
7
7
|
|
|
8
|
+
export interface FilterPreset {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly overlay: string;
|
|
12
|
+
readonly opacity: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
export type LayerType = "text" | "image" | "video" | "shape";
|
|
9
16
|
|
|
10
17
|
export type TransitionType = "fade" | "slide" | "zoom" | "wipe" | "none";
|
|
@@ -127,6 +134,7 @@ export interface Scene {
|
|
|
127
134
|
layers: Layer[];
|
|
128
135
|
transition: Transition;
|
|
129
136
|
audio?: Audio;
|
|
137
|
+
filter?: FilterPreset;
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
export interface ExportSettings {
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export type {
|
|
|
33
33
|
AddTextLayerData,
|
|
34
34
|
AddImageLayerData,
|
|
35
35
|
AddShapeLayerData,
|
|
36
|
+
FilterPreset,
|
|
36
37
|
} from "./domain/entities";
|
|
37
38
|
|
|
38
39
|
// =============================================================================
|
|
@@ -73,6 +74,9 @@ export {
|
|
|
73
74
|
DraggableLayer,
|
|
74
75
|
ImageLayerEditor,
|
|
75
76
|
ExportDialog,
|
|
77
|
+
SpeedControlPanel,
|
|
78
|
+
VideoFilterPicker,
|
|
79
|
+
CollageEditorCanvas,
|
|
76
80
|
} from "./presentation/components";
|
|
77
81
|
|
|
78
82
|
export { useEditorLayers } from "./presentation/hooks/useEditorLayers";
|
|
@@ -97,6 +101,8 @@ export { useLayerActions } from "./presentation/hooks/useLayerActions";
|
|
|
97
101
|
export { useSceneActions } from "./presentation/hooks/useSceneActions";
|
|
98
102
|
export { useMenuActions } from "./presentation/hooks/useMenuActions";
|
|
99
103
|
export { useExportActions } from "./presentation/hooks/useExportActions";
|
|
104
|
+
export { useCollageEditor } from "./presentation/hooks/useCollageEditor";
|
|
105
|
+
export type { UseCollageEditorReturn } from "./presentation/hooks/useCollageEditor";
|
|
100
106
|
|
|
101
107
|
// =============================================================================
|
|
102
108
|
// 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;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filter Presets
|
|
3
|
+
* Color overlay-based filters for video and photo editing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FilterPreset } from "../../domain/entities";
|
|
7
|
+
|
|
8
|
+
export const FILTER_PRESETS: FilterPreset[] = [
|
|
9
|
+
{ id: "none", name: "Original", overlay: "transparent", opacity: 0 },
|
|
10
|
+
{ id: "vivid", name: "Vivid", overlay: "rgba(255, 100, 50, 0.15)", opacity: 0.15 },
|
|
11
|
+
{ id: "warm", name: "Warm", overlay: "rgba(255, 170, 50, 0.2)", opacity: 0.2 },
|
|
12
|
+
{ id: "cool", name: "Cool", overlay: "rgba(50, 130, 255, 0.18)", opacity: 0.18 },
|
|
13
|
+
{ id: "bw", name: "B&W", overlay: "rgba(0, 0, 0, 0)", opacity: 0 },
|
|
14
|
+
{ id: "vintage", name: "Vintage", overlay: "rgba(200, 150, 80, 0.25)", opacity: 0.25 },
|
|
15
|
+
{ id: "fade", name: "Fade", overlay: "rgba(230, 220, 210, 0.3)", opacity: 0.3 },
|
|
16
|
+
{ id: "dramatic", name: "Drama", overlay: "rgba(20, 20, 40, 0.2)", opacity: 0.2 },
|
|
17
|
+
{ id: "rose", name: "Rose", overlay: "rgba(255, 100, 150, 0.18)", opacity: 0.18 },
|
|
18
|
+
{ id: "emerald", name: "Emerald", overlay: "rgba(0, 200, 100, 0.15)", opacity: 0.15 },
|
|
19
|
+
{ id: "cinema", name: "Cinema", overlay: "rgba(30, 50, 80, 0.22)", opacity: 0.22 },
|
|
20
|
+
{ id: "retro", name: "Retro DV", overlay: "rgba(180, 140, 60, 0.3)", opacity: 0.3 },
|
|
21
|
+
{ id: "glitch", name: "Glitch", overlay: "rgba(0, 255, 200, 0.12)", opacity: 0.12 },
|
|
22
|
+
{ id: "noir", name: "Noir", overlay: "rgba(0, 0, 0, 0.1)", opacity: 0.1 },
|
|
23
|
+
{ id: "sunset", name: "Sunset", overlay: "rgba(255, 120, 50, 0.22)", opacity: 0.22 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_FILTER = FILTER_PRESETS[0];
|
|
@@ -9,3 +9,6 @@ export * from "./shape-layer.constants";
|
|
|
9
9
|
export * from "./animation-layer.constants";
|
|
10
10
|
export * from "./image-layer.constants";
|
|
11
11
|
export * from "./export.constants";
|
|
12
|
+
export * from "./filter.constants";
|
|
13
|
+
export * from "./speed.constants";
|
|
14
|
+
export * from "./collage.constants";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speed Presets
|
|
3
|
+
* Playback rate options for video editing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SpeedPreset {
|
|
7
|
+
readonly label: string;
|
|
8
|
+
readonly value: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const SPEED_PRESETS: SpeedPreset[] = [
|
|
12
|
+
{ label: "0.25x", value: 0.25 },
|
|
13
|
+
{ label: "0.5x", value: 0.5 },
|
|
14
|
+
{ label: "0.75x", value: 0.75 },
|
|
15
|
+
{ label: "1x", value: 1 },
|
|
16
|
+
{ label: "1.5x", value: 1.5 },
|
|
17
|
+
{ label: "2x", value: 2 },
|
|
18
|
+
{ label: "3x", value: 3 },
|
|
19
|
+
{ label: "4x", value: 4 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_PLAYBACK_RATE = 1;
|
|
@@ -44,6 +44,8 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
44
44
|
nativeControls = true,
|
|
45
45
|
contentFit = "cover",
|
|
46
46
|
style,
|
|
47
|
+
playbackRate = 1,
|
|
48
|
+
filterOverlay,
|
|
47
49
|
}) => {
|
|
48
50
|
// IMPORTANT: Call useResponsive BEFORE useAppDesignTokens to maintain hook order
|
|
49
51
|
const { width: screenWidth, horizontalPadding } = useResponsive();
|
|
@@ -60,6 +62,7 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
60
62
|
loop,
|
|
61
63
|
muted,
|
|
62
64
|
autoPlay,
|
|
65
|
+
playbackRate,
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
const handlePlay = useCallback(() => {
|
|
@@ -147,6 +150,15 @@ export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
|
147
150
|
contentFit={contentFit}
|
|
148
151
|
nativeControls={nativeControls}
|
|
149
152
|
/>
|
|
153
|
+
{filterOverlay && filterOverlay.opacity > 0 && (
|
|
154
|
+
<View
|
|
155
|
+
style={[
|
|
156
|
+
StyleSheet.absoluteFill,
|
|
157
|
+
{ backgroundColor: filterOverlay.overlay, opacity: filterOverlay.opacity },
|
|
158
|
+
]}
|
|
159
|
+
pointerEvents="none"
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
150
162
|
</View>
|
|
151
163
|
);
|
|
152
164
|
}
|
|
@@ -36,10 +36,11 @@ declare const __DEV__: boolean;
|
|
|
36
36
|
export const useVideoPlayerControl = (
|
|
37
37
|
config: VideoPlayerConfig,
|
|
38
38
|
): UseVideoPlayerControlResult => {
|
|
39
|
-
const { source, loop = true, muted = false, autoPlay = false } = config;
|
|
39
|
+
const { source, loop = true, muted = false, autoPlay = false, playbackRate: initialRate = 1 } = config;
|
|
40
40
|
|
|
41
41
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
42
42
|
const [isLoading, setIsLoading] = useState(true);
|
|
43
|
+
const [playbackRate, setPlaybackRateState] = useState(initialRate);
|
|
43
44
|
|
|
44
45
|
const player = useExpoVideoPlayer(source || "", (p: any) => {
|
|
45
46
|
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
@@ -85,18 +86,25 @@ export const useVideoPlayerControl = (
|
|
|
85
86
|
if (success) setIsPlaying(!isPlaying);
|
|
86
87
|
}, [player, isPlayerValid, isPlaying]);
|
|
87
88
|
|
|
89
|
+
const setPlaybackRate = useCallback((rate: number) => {
|
|
90
|
+
if (!isPlayerValid || !player) return;
|
|
91
|
+
(player as any).playbackRate = rate;
|
|
92
|
+
setPlaybackRateState(rate);
|
|
93
|
+
}, [player, isPlayerValid]);
|
|
94
|
+
|
|
88
95
|
const state: VideoPlayerState = useMemo(
|
|
89
96
|
() => ({
|
|
90
97
|
isPlaying,
|
|
91
98
|
isPlayerValid,
|
|
92
99
|
isLoading: isLoading && Boolean(source),
|
|
100
|
+
playbackRate,
|
|
93
101
|
}),
|
|
94
|
-
[isPlaying, isPlayerValid, isLoading, source],
|
|
102
|
+
[isPlaying, isPlayerValid, isLoading, source, playbackRate],
|
|
95
103
|
);
|
|
96
104
|
|
|
97
105
|
const controls: VideoPlayerControls = useMemo(
|
|
98
|
-
() => ({ play, pause, toggle }),
|
|
99
|
-
[play, pause, toggle],
|
|
106
|
+
() => ({ play, pause, toggle, setPlaybackRate }),
|
|
107
|
+
[play, pause, toggle, setPlaybackRate],
|
|
100
108
|
);
|
|
101
109
|
|
|
102
110
|
return { player, state, controls };
|
|
@@ -14,6 +14,7 @@ export interface VideoPlayerConfig {
|
|
|
14
14
|
readonly loop?: boolean;
|
|
15
15
|
readonly muted?: boolean;
|
|
16
16
|
readonly autoPlay?: boolean;
|
|
17
|
+
readonly playbackRate?: number;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -23,6 +24,7 @@ export interface VideoPlayerState {
|
|
|
23
24
|
readonly isPlaying: boolean;
|
|
24
25
|
readonly isPlayerValid: boolean;
|
|
25
26
|
readonly isLoading: boolean;
|
|
27
|
+
readonly playbackRate: number;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -32,6 +34,7 @@ export interface VideoPlayerControls {
|
|
|
32
34
|
readonly play: () => void;
|
|
33
35
|
readonly pause: () => void;
|
|
34
36
|
readonly toggle: () => void;
|
|
37
|
+
readonly setPlaybackRate: (rate: number) => void;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -69,6 +72,8 @@ export interface VideoPlayerProps {
|
|
|
69
72
|
readonly style?: ViewStyle;
|
|
70
73
|
readonly contentFit?: "contain" | "cover" | "fill";
|
|
71
74
|
readonly thumbnailUrl?: string;
|
|
75
|
+
readonly playbackRate?: number;
|
|
76
|
+
readonly filterOverlay?: { overlay: string; opacity: number };
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
export type { VideoPlayer } from "expo-video";
|
|
@@ -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
|
+
};
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
ScrollView,
|
|
10
10
|
TouchableOpacity,
|
|
11
11
|
StyleSheet,
|
|
12
|
-
Alert,
|
|
13
12
|
} from "react-native";
|
|
14
13
|
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
15
14
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
@@ -21,6 +20,8 @@ interface EditorToolPanelProps {
|
|
|
21
20
|
onAddShape: () => void;
|
|
22
21
|
onAudio: () => void;
|
|
23
22
|
hasAudio: boolean;
|
|
23
|
+
onFilters?: () => void;
|
|
24
|
+
onSpeed?: () => void;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
export const EditorToolPanel: React.FC<EditorToolPanelProps> = ({
|
|
@@ -29,6 +30,8 @@ export const EditorToolPanel: React.FC<EditorToolPanelProps> = ({
|
|
|
29
30
|
onAddShape,
|
|
30
31
|
onAudio,
|
|
31
32
|
hasAudio,
|
|
33
|
+
onFilters,
|
|
34
|
+
onSpeed,
|
|
32
35
|
}) => {
|
|
33
36
|
const { t } = useLocalization();
|
|
34
37
|
const tokens = useAppDesignTokens();
|
|
@@ -124,26 +127,41 @@ export const EditorToolPanel: React.FC<EditorToolPanelProps> = ({
|
|
|
124
127
|
)}
|
|
125
128
|
</TouchableOpacity>
|
|
126
129
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
t("editor.tools.effects"),
|
|
135
|
-
t("editor.tools.effectsComingSoon"),
|
|
136
|
-
)
|
|
137
|
-
}
|
|
138
|
-
>
|
|
139
|
-
<AtomicIcon name="sparkles-outline" size="md" color="primary" />
|
|
140
|
-
<AtomicText
|
|
141
|
-
type="labelSmall"
|
|
142
|
-
style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
|
|
130
|
+
{onFilters && (
|
|
131
|
+
<TouchableOpacity
|
|
132
|
+
style={[
|
|
133
|
+
styles.toolButton,
|
|
134
|
+
{ backgroundColor: tokens.colors.backgroundPrimary },
|
|
135
|
+
]}
|
|
136
|
+
onPress={onFilters}
|
|
143
137
|
>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
138
|
+
<AtomicIcon name="sparkles" size="md" color="primary" />
|
|
139
|
+
<AtomicText
|
|
140
|
+
type="labelSmall"
|
|
141
|
+
style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
|
|
142
|
+
>
|
|
143
|
+
{t("editor.tools.filters") || "Filters"}
|
|
144
|
+
</AtomicText>
|
|
145
|
+
</TouchableOpacity>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{onSpeed && (
|
|
149
|
+
<TouchableOpacity
|
|
150
|
+
style={[
|
|
151
|
+
styles.toolButton,
|
|
152
|
+
{ backgroundColor: tokens.colors.backgroundPrimary },
|
|
153
|
+
]}
|
|
154
|
+
onPress={onSpeed}
|
|
155
|
+
>
|
|
156
|
+
<AtomicIcon name="flash" size="md" color="primary" />
|
|
157
|
+
<AtomicText
|
|
158
|
+
type="labelSmall"
|
|
159
|
+
style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
|
|
160
|
+
>
|
|
161
|
+
{t("editor.tools.speed") || "Speed"}
|
|
162
|
+
</AtomicText>
|
|
163
|
+
</TouchableOpacity>
|
|
164
|
+
)}
|
|
147
165
|
</ScrollView>
|
|
148
166
|
</View>
|
|
149
167
|
);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SpeedControlPanel Component
|
|
3
|
+
* Horizontal speed selector for video playback rate
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, ScrollView, TouchableOpacity, StyleSheet } 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 { SPEED_PRESETS } from "../../infrastructure/constants/speed.constants";
|
|
11
|
+
|
|
12
|
+
interface SpeedControlPanelProps {
|
|
13
|
+
playbackRate: number;
|
|
14
|
+
onChangeRate: (rate: number) => void;
|
|
15
|
+
t: (key: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const SpeedControlPanel: React.FC<SpeedControlPanelProps> = ({
|
|
19
|
+
playbackRate,
|
|
20
|
+
onChangeRate,
|
|
21
|
+
t,
|
|
22
|
+
}) => {
|
|
23
|
+
const tokens = useAppDesignTokens();
|
|
24
|
+
|
|
25
|
+
const styles = useMemo(
|
|
26
|
+
() =>
|
|
27
|
+
StyleSheet.create({
|
|
28
|
+
container: { paddingVertical: tokens.spacing.sm },
|
|
29
|
+
label: {
|
|
30
|
+
paddingHorizontal: tokens.spacing.md,
|
|
31
|
+
marginBottom: tokens.spacing.sm,
|
|
32
|
+
},
|
|
33
|
+
scroll: { paddingHorizontal: tokens.spacing.md, gap: tokens.spacing.sm },
|
|
34
|
+
btn: {
|
|
35
|
+
paddingHorizontal: tokens.spacing.md,
|
|
36
|
+
paddingVertical: tokens.spacing.sm,
|
|
37
|
+
borderRadius: tokens.borders.radius.full,
|
|
38
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
39
|
+
},
|
|
40
|
+
btnActive: {
|
|
41
|
+
backgroundColor: tokens.colors.primary,
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
[tokens],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<View style={styles.container}>
|
|
49
|
+
<AtomicText type="labelSmall" color="textSecondary" style={styles.label}>
|
|
50
|
+
{t("editor.speed") || "Speed"}
|
|
51
|
+
</AtomicText>
|
|
52
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
|
53
|
+
{SPEED_PRESETS.map((preset) => {
|
|
54
|
+
const isActive = playbackRate === preset.value;
|
|
55
|
+
return (
|
|
56
|
+
<TouchableOpacity
|
|
57
|
+
key={preset.value}
|
|
58
|
+
style={[styles.btn, isActive && styles.btnActive]}
|
|
59
|
+
onPress={() => onChangeRate(preset.value)}
|
|
60
|
+
accessibilityLabel={preset.label}
|
|
61
|
+
accessibilityRole="button"
|
|
62
|
+
accessibilityState={{ selected: isActive }}
|
|
63
|
+
>
|
|
64
|
+
<AtomicText
|
|
65
|
+
fontWeight={isActive ? "bold" : "normal"}
|
|
66
|
+
color={isActive ? "onPrimary" : "textSecondary"}
|
|
67
|
+
>
|
|
68
|
+
{preset.label}
|
|
69
|
+
</AtomicText>
|
|
70
|
+
</TouchableOpacity>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</ScrollView>
|
|
74
|
+
</View>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoFilterPicker Component
|
|
3
|
+
* Horizontal filter selector with color preview dots
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo } from "react";
|
|
7
|
+
import { View, ScrollView, TouchableOpacity, StyleSheet } 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 { FILTER_PRESETS } from "../../infrastructure/constants/filter.constants";
|
|
11
|
+
import type { FilterPreset } from "../../domain/entities";
|
|
12
|
+
|
|
13
|
+
interface VideoFilterPickerProps {
|
|
14
|
+
activeFilter: FilterPreset;
|
|
15
|
+
onSelectFilter: (filter: FilterPreset) => void;
|
|
16
|
+
t: (key: string) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const VideoFilterPicker: React.FC<VideoFilterPickerProps> = ({
|
|
20
|
+
activeFilter,
|
|
21
|
+
onSelectFilter,
|
|
22
|
+
t,
|
|
23
|
+
}) => {
|
|
24
|
+
const tokens = useAppDesignTokens();
|
|
25
|
+
|
|
26
|
+
const styles = useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
StyleSheet.create({
|
|
29
|
+
container: { paddingVertical: tokens.spacing.sm },
|
|
30
|
+
label: {
|
|
31
|
+
paddingHorizontal: tokens.spacing.md,
|
|
32
|
+
marginBottom: tokens.spacing.sm,
|
|
33
|
+
},
|
|
34
|
+
scroll: { paddingHorizontal: tokens.spacing.md, gap: tokens.spacing.md },
|
|
35
|
+
item: { alignItems: "center", gap: tokens.spacing.xs },
|
|
36
|
+
dot: {
|
|
37
|
+
width: 48,
|
|
38
|
+
height: 48,
|
|
39
|
+
borderRadius: 24,
|
|
40
|
+
borderWidth: 2,
|
|
41
|
+
borderColor: "transparent",
|
|
42
|
+
alignItems: "center",
|
|
43
|
+
justifyContent: "center",
|
|
44
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
45
|
+
},
|
|
46
|
+
dotActive: {
|
|
47
|
+
borderColor: tokens.colors.primary,
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
[tokens],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View style={styles.container}>
|
|
55
|
+
<AtomicText type="labelSmall" color="textSecondary" style={styles.label}>
|
|
56
|
+
{t("editor.filters") || "Filters"}
|
|
57
|
+
</AtomicText>
|
|
58
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
|
59
|
+
{FILTER_PRESETS.map((filter) => {
|
|
60
|
+
const isActive = activeFilter.id === filter.id;
|
|
61
|
+
const isNone = filter.id === "none";
|
|
62
|
+
return (
|
|
63
|
+
<TouchableOpacity
|
|
64
|
+
key={filter.id}
|
|
65
|
+
style={styles.item}
|
|
66
|
+
onPress={() => onSelectFilter(filter)}
|
|
67
|
+
accessibilityLabel={filter.name}
|
|
68
|
+
accessibilityRole="button"
|
|
69
|
+
accessibilityState={{ selected: isActive }}
|
|
70
|
+
>
|
|
71
|
+
<View
|
|
72
|
+
style={[
|
|
73
|
+
styles.dot,
|
|
74
|
+
isActive && styles.dotActive,
|
|
75
|
+
!isNone && { backgroundColor: filter.overlay },
|
|
76
|
+
]}
|
|
77
|
+
>
|
|
78
|
+
{isNone && (
|
|
79
|
+
<AtomicIcon name="close" size="sm" color="textSecondary" />
|
|
80
|
+
)}
|
|
81
|
+
</View>
|
|
82
|
+
<AtomicText
|
|
83
|
+
type="labelSmall"
|
|
84
|
+
color={isActive ? "primary" : "textSecondary"}
|
|
85
|
+
>
|
|
86
|
+
{filter.name}
|
|
87
|
+
</AtomicText>
|
|
88
|
+
</TouchableOpacity>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</ScrollView>
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
@@ -15,3 +15,6 @@ export * from "./AnimationEditor";
|
|
|
15
15
|
export * from "./DraggableLayer";
|
|
16
16
|
export * from "./ImageLayerEditor";
|
|
17
17
|
export * from "./ExportDialog";
|
|
18
|
+
export * from "./SpeedControlPanel";
|
|
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
|
+
}
|