@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 +1 -1
- package/src/VideoEditor.tsx +210 -0
- package/src/index.ts +6 -0
- package/src/infrastructure/constants/collage.constants.ts +74 -0
- package/src/infrastructure/constants/index.ts +1 -0
- package/src/presentation/components/CollageEditorCanvas.tsx +285 -0
- package/src/presentation/components/index.ts +1 -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.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;
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
}
|