@umituz/react-native-photo-editor 1.0.1

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 ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@umituz/react-native-photo-editor",
3
+ "version": "1.0.1",
4
+ "description": "A powerful, generic photo editor for React Native",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "scripts": {
11
+ "lint": "eslint .",
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "keywords": [
15
+ "react-native",
16
+ "photo-editor",
17
+ "image-editor"
18
+ ],
19
+ "author": "UmitUZ",
20
+ "license": "MIT",
21
+ "peerDependencies": {
22
+ "react": "*",
23
+ "react-native": "*",
24
+ "@umituz/react-native-design-system": "*"
25
+ },
26
+ "dependencies": {
27
+ "expo-image-picker": "*",
28
+ "expo-media-library": "*",
29
+ "expo-file-system": "*",
30
+ "expo-image": "*"
31
+ }
32
+ }
@@ -0,0 +1,153 @@
1
+ import React, { useMemo } from "react";
2
+ import { View, ScrollView, TouchableOpacity } from "react-native";
3
+ import {
4
+ useAppDesignTokens,
5
+ AtomicText,
6
+ AtomicIcon,
7
+ BottomSheetModal,
8
+ SafeBottomSheetModalProvider,
9
+ useSafeAreaInsets,
10
+ } from "@umituz/react-native-design-system";
11
+
12
+ import { EditorCanvas } from "./components/EditorCanvas";
13
+ import { EditorToolbar } from "./components/EditorToolbar";
14
+ import { FontControls } from "./components/FontControls";
15
+ import { StickerPicker } from "./components/StickerPicker";
16
+ import { FilterPicker } from "./components/FilterPicker";
17
+ import { LayerManager } from "./components/LayerManager";
18
+ import { TextEditorSheet } from "./components/TextEditorSheet";
19
+ import { createEditorStyles } from "./styles";
20
+ import { usePhotoEditorUI } from "./hooks/usePhotoEditorUI";
21
+
22
+ export interface PhotoEditorProps {
23
+ imageUri: string;
24
+ onSave?: (uri: string) => void;
25
+ onClose: () => void;
26
+ title?: string;
27
+ onShare?: () => void;
28
+ customTools?: React.ReactNode;
29
+ initialCaption?: string;
30
+ t: (key: string) => string;
31
+ }
32
+
33
+ const FONTS = ["Impact", "Comic", "Serif", "Retro"] as const;
34
+
35
+ export const PhotoEditor: React.FC<PhotoEditorProps> = ({
36
+ imageUri,
37
+ onSave,
38
+ onClose,
39
+ title = "Photo Editor",
40
+ customTools,
41
+ initialCaption,
42
+ t,
43
+ }) => {
44
+ const tokens = useAppDesignTokens();
45
+ const insets = useSafeAreaInsets();
46
+ const styles = useMemo(() => createEditorStyles(tokens, insets), [tokens, insets]);
47
+
48
+ const {
49
+ // Refs
50
+ textEditorSheetRef,
51
+ stickerSheetRef,
52
+ filterSheetRef,
53
+ layerSheetRef,
54
+ // State
55
+ selectedFont,
56
+ setSelectedFont,
57
+ fontSize,
58
+ setFontSize,
59
+ editingText,
60
+ setEditingText,
61
+ selectedFilter,
62
+ // Domain State
63
+ layers,
64
+ activeLayerId,
65
+ // Actions
66
+ updateLayer,
67
+ deleteLayer,
68
+ selectLayer,
69
+ handleAddText,
70
+ handleTextLayerTap,
71
+ handleSaveText,
72
+ handleSelectFilter,
73
+ handleSelectSticker,
74
+ } = usePhotoEditorUI(initialCaption, tokens);
75
+
76
+ return (
77
+ <SafeBottomSheetModalProvider>
78
+ <View style={styles.container}>
79
+ <View style={styles.header}>
80
+ <TouchableOpacity style={styles.headerButton} onPress={onClose}>
81
+ <AtomicIcon name="close" size="md" color="textPrimary" />
82
+ </TouchableOpacity>
83
+ <AtomicText style={styles.headerTitle}>{title}</AtomicText>
84
+ <TouchableOpacity style={styles.postButton} onPress={() => onSave?.(imageUri)}>
85
+ <AtomicText style={styles.postButtonText}>{t("preview.share") || "Share"}</AtomicText>
86
+ </TouchableOpacity>
87
+ </View>
88
+
89
+ <ScrollView
90
+ contentContainerStyle={styles.scrollContent}
91
+ showsVerticalScrollIndicator={false}
92
+ >
93
+ <EditorCanvas
94
+ imageUrl={imageUri}
95
+ layers={layers}
96
+ activeLayerId={activeLayerId}
97
+ onLayerTap={handleTextLayerTap}
98
+ onLayerMove={(id, x, y) => updateLayer(id, { x, y })}
99
+ styles={styles}
100
+ />
101
+ {customTools}
102
+ <FontControls
103
+ fontSize={fontSize}
104
+ selectedFont={selectedFont}
105
+ fonts={FONTS}
106
+ onFontSizeChange={(s) => setFontSize(Math.max(12, Math.min(96, s)))}
107
+ onFontSelect={setSelectedFont}
108
+ styles={styles}
109
+ />
110
+ </ScrollView>
111
+
112
+ <EditorToolbar
113
+ onAddText={handleAddText}
114
+ onAddSticker={() => stickerSheetRef.current?.present()}
115
+ onOpenFilters={() => filterSheetRef.current?.present()}
116
+ onOpenLayers={() => layerSheetRef.current?.present()}
117
+ styles={styles}
118
+ t={t}
119
+ />
120
+
121
+ <BottomSheetModal ref={textEditorSheetRef} snapPoints={["40%"]}>
122
+ <TextEditorSheet
123
+ value={editingText}
124
+ onChange={setEditingText}
125
+ onSave={handleSaveText}
126
+ t={t}
127
+ />
128
+ </BottomSheetModal>
129
+
130
+ <BottomSheetModal ref={stickerSheetRef} snapPoints={["50%"]}>
131
+ <StickerPicker onSelectSticker={handleSelectSticker} />
132
+ </BottomSheetModal>
133
+
134
+ <BottomSheetModal ref={filterSheetRef} snapPoints={["40%"]}>
135
+ <FilterPicker selectedFilter={selectedFilter} onSelectFilter={handleSelectFilter} />
136
+ </BottomSheetModal>
137
+
138
+ <BottomSheetModal ref={layerSheetRef} snapPoints={["50%"]}>
139
+ <LayerManager
140
+ layers={layers}
141
+ activeLayerId={activeLayerId}
142
+ onSelectLayer={(id) => {
143
+ selectLayer(id);
144
+ layerSheetRef.current?.dismiss();
145
+ }}
146
+ onDeleteLayer={deleteLayer}
147
+ t={t}
148
+ />
149
+ </BottomSheetModal>
150
+ </View>
151
+ </SafeBottomSheetModalProvider>
152
+ );
153
+ };
@@ -0,0 +1,174 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ View,
4
+ ScrollView,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ActivityIndicator,
8
+ } from "react-native";
9
+ import {
10
+ AtomicText,
11
+ AtomicIcon,
12
+ useAppDesignTokens,
13
+ } from "@umituz/react-native-design-system";
14
+
15
+ interface AIMagicSheetProps {
16
+ onGenerateCaption: (style: string) => void;
17
+ isLoading?: boolean;
18
+ t: (key: string) => string;
19
+ }
20
+
21
+ const AI_STYLES = [
22
+ { id: "viral", label: "✨ Viral", desc: "Catchy & shareable" },
23
+ { id: "funny", label: "😂 Funny", desc: "Humor that connects" },
24
+ { id: "savage", label: "🔥 Savage", desc: "Bold & edgy" },
25
+ { id: "wholesome", label: "💕 Wholesome", desc: "Warm & positive" },
26
+ { id: "sarcastic", label: "😏 Sarcastic", desc: "Witty & ironic" },
27
+ { id: "relatable", label: "🎯 Relatable", desc: "Everyone gets it" },
28
+ ];
29
+
30
+ export const AIMagicSheet: React.FC<AIMagicSheetProps> = ({
31
+ onGenerateCaption,
32
+ isLoading = false,
33
+ t,
34
+ }) => {
35
+ const tokens = useAppDesignTokens();
36
+ const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
37
+
38
+ const styles = StyleSheet.create({
39
+ container: { padding: 16 },
40
+ header: {
41
+ flexDirection: "row",
42
+ alignItems: "center",
43
+ gap: 8,
44
+ marginBottom: 16,
45
+ },
46
+ title: {
47
+ fontSize: 18,
48
+ fontWeight: "bold",
49
+ color: tokens.colors.textPrimary,
50
+ },
51
+ subtitle: {
52
+ fontSize: 14,
53
+ color: tokens.colors.textSecondary,
54
+ marginBottom: 16,
55
+ },
56
+ grid: { gap: 12 },
57
+ styleCard: {
58
+ flexDirection: "row",
59
+ alignItems: "center",
60
+ padding: 16,
61
+ backgroundColor: tokens.colors.surfaceVariant,
62
+ borderRadius: 16,
63
+ borderWidth: 2,
64
+ borderColor: "transparent",
65
+ },
66
+ styleCardActive: {
67
+ borderColor: tokens.colors.primary,
68
+ backgroundColor: tokens.colors.primaryContainer,
69
+ },
70
+ styleInfo: { flex: 1, marginLeft: 12 },
71
+ styleLabel: {
72
+ fontSize: 16,
73
+ fontWeight: "bold",
74
+ color: tokens.colors.textPrimary,
75
+ },
76
+ styleLabelActive: { color: tokens.colors.primary },
77
+ styleDesc: {
78
+ fontSize: 12,
79
+ color: tokens.colors.textSecondary,
80
+ marginTop: 2,
81
+ },
82
+ generateButton: {
83
+ backgroundColor: tokens.colors.primary,
84
+ borderRadius: 999,
85
+ padding: 16,
86
+ flexDirection: "row",
87
+ alignItems: "center",
88
+ justifyContent: "center",
89
+ gap: 8,
90
+ marginTop: 16,
91
+ },
92
+ generateButtonDisabled: { opacity: 0.5 },
93
+ generateButtonText: {
94
+ color: tokens.colors.onPrimary,
95
+ fontWeight: "bold",
96
+ fontSize: 16,
97
+ },
98
+ });
99
+
100
+ const handleGenerate = () => {
101
+ if (selectedStyle) {
102
+ onGenerateCaption(selectedStyle);
103
+ }
104
+ };
105
+
106
+ return (
107
+ <View style={styles.container}>
108
+ <View style={styles.header}>
109
+ <AtomicIcon name="sparkles" size="md" color="primary" />
110
+ <AtomicText style={styles.title}>AI Caption Magic</AtomicText>
111
+ </View>
112
+ <AtomicText style={styles.subtitle}>
113
+ Choose a style and let AI create the perfect caption
114
+ </AtomicText>
115
+
116
+ <ScrollView showsVerticalScrollIndicator={false}>
117
+ <View style={styles.grid}>
118
+ {AI_STYLES.map((style) => (
119
+ <TouchableOpacity
120
+ key={style.id}
121
+ style={[
122
+ styles.styleCard,
123
+ selectedStyle === style.id && styles.styleCardActive,
124
+ ]}
125
+ onPress={() => setSelectedStyle(style.id)}
126
+ >
127
+ <AtomicText style={{ fontSize: 28 }}>
128
+ {style.label.split(" ")[0]}
129
+ </AtomicText>
130
+ <View style={styles.styleInfo}>
131
+ <AtomicText
132
+ style={[
133
+ styles.styleLabel,
134
+ selectedStyle === style.id && styles.styleLabelActive,
135
+ ]}
136
+ >
137
+ {style.label.split(" ").slice(1).join(" ")}
138
+ </AtomicText>
139
+ <AtomicText style={styles.styleDesc}>{style.desc}</AtomicText>
140
+ </View>
141
+ {selectedStyle === style.id && (
142
+ <AtomicIcon name="checkmark-circle" size="md" color="primary" />
143
+ )}
144
+ </TouchableOpacity>
145
+ ))}
146
+ </View>
147
+ </ScrollView>
148
+
149
+ <TouchableOpacity
150
+ style={[
151
+ styles.generateButton,
152
+ !selectedStyle && styles.generateButtonDisabled,
153
+ ]}
154
+ onPress={handleGenerate}
155
+ disabled={!selectedStyle || isLoading}
156
+ >
157
+ {isLoading ? (
158
+ <ActivityIndicator color={tokens.colors.onPrimary} />
159
+ ) : (
160
+ <>
161
+ <AtomicIcon
162
+ name="sparkles"
163
+ size="sm"
164
+ customColor={tokens.colors.onPrimary}
165
+ />
166
+ <AtomicText style={styles.generateButtonText}>
167
+ Generate Caption
168
+ </AtomicText>
169
+ </>
170
+ )}
171
+ </TouchableOpacity>
172
+ </View>
173
+ );
174
+ };
@@ -0,0 +1,100 @@
1
+ import React from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
+ import Animated, {
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ runOnJS,
8
+ } from "react-native-reanimated";
9
+ import {
10
+ AtomicText,
11
+ useAppDesignTokens,
12
+ } from "@umituz/react-native-design-system";
13
+
14
+ interface DraggableStickerProps {
15
+ uri: string;
16
+ initialX: number;
17
+ initialY: number;
18
+ rotation?: number;
19
+ scale?: number;
20
+ opacity?: number;
21
+ onDragEnd: (x: number, y: number) => void;
22
+ onPress: () => void;
23
+ isSelected?: boolean;
24
+ }
25
+
26
+ export const DraggableSticker: React.FC<DraggableStickerProps> = ({
27
+ uri,
28
+ initialX,
29
+ initialY,
30
+ rotation = 0,
31
+ scale = 1,
32
+ opacity = 1,
33
+ onDragEnd,
34
+ onPress,
35
+ isSelected,
36
+ }) => {
37
+ const tokens = useAppDesignTokens();
38
+ const translateX = useSharedValue(initialX);
39
+ const translateY = useSharedValue(initialY);
40
+ const offset = useSharedValue({ x: 0, y: 0 });
41
+
42
+ const isEmoji = uri.length <= 4 && !uri.startsWith("http");
43
+
44
+ const drag = Gesture.Pan()
45
+ .minDistance(5)
46
+ .onStart(() => {
47
+ offset.value = { x: translateX.value, y: translateY.value };
48
+ })
49
+ .onUpdate((event) => {
50
+ translateX.value = offset.value.x + event.translationX;
51
+ translateY.value = offset.value.y + event.translationY;
52
+ })
53
+ .onEnd(() => {
54
+ runOnJS(onDragEnd)(translateX.value, translateY.value);
55
+ });
56
+
57
+ const tap = Gesture.Tap()
58
+ .maxDistance(5)
59
+ .onEnd(() => {
60
+ runOnJS(onPress)();
61
+ });
62
+
63
+ const gesture = Gesture.Exclusive(drag, tap);
64
+
65
+ const animatedStyle = useAnimatedStyle(() => ({
66
+ transform: [
67
+ { translateX: translateX.value },
68
+ { translateY: translateY.value },
69
+ { rotate: `${rotation}deg` },
70
+ { scale: scale },
71
+ ],
72
+ opacity: opacity,
73
+ zIndex: isSelected ? 100 : 50,
74
+ }));
75
+
76
+ const styles = StyleSheet.create({
77
+ container: { position: "absolute", left: 0, top: 0 },
78
+ emojiContainer: {
79
+ padding: 4,
80
+ borderRadius: 8,
81
+ borderWidth: isSelected ? 2 : 0,
82
+ borderColor: tokens.colors.primary,
83
+ borderStyle: "dashed",
84
+ backgroundColor: isSelected
85
+ ? tokens.colors.primary + "20"
86
+ : "transparent",
87
+ },
88
+ emoji: { fontSize: 64 },
89
+ });
90
+
91
+ return (
92
+ <GestureDetector gesture={gesture}>
93
+ <Animated.View style={[styles.container, animatedStyle]}>
94
+ <View style={styles.emojiContainer}>
95
+ {isEmoji ? <AtomicText style={styles.emoji}>{uri}</AtomicText> : null}
96
+ </View>
97
+ </Animated.View>
98
+ </GestureDetector>
99
+ );
100
+ };
@@ -0,0 +1,143 @@
1
+ import React, { useMemo } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Gesture, GestureDetector } from "react-native-gesture-handler";
4
+ import Animated, {
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ runOnJS,
8
+ } from "react-native-reanimated";
9
+ import {
10
+ AtomicText,
11
+ useAppDesignTokens,
12
+ } from "@umituz/react-native-design-system";
13
+ import { TextAlign } from "../../domain/Editor.types";
14
+
15
+ interface DraggableTextProps {
16
+ text: string;
17
+ color: string;
18
+ fontSize?: number;
19
+ fontFamily?: string;
20
+ textAlign?: TextAlign;
21
+ rotation?: number;
22
+ scale?: number;
23
+ opacity?: number;
24
+ backgroundColor?: string;
25
+ strokeColor?: string;
26
+ strokeWidth?: number;
27
+ initialX: number;
28
+ initialY: number;
29
+ onDragEnd: (x: number, y: number) => void;
30
+ onPress: () => void;
31
+ isSelected?: boolean;
32
+ }
33
+
34
+ export const DraggableText: React.FC<DraggableTextProps> = ({
35
+ text,
36
+ color,
37
+ fontSize = 24,
38
+ fontFamily = "System",
39
+ textAlign = "center",
40
+ rotation = 0,
41
+ scale = 1,
42
+ opacity = 1,
43
+ backgroundColor = "transparent",
44
+ strokeColor,
45
+ strokeWidth = 2,
46
+ initialX,
47
+ initialY,
48
+ onDragEnd,
49
+ onPress,
50
+ isSelected,
51
+ }) => {
52
+ const tokens = useAppDesignTokens();
53
+ const translateX = useSharedValue(initialX);
54
+ const translateY = useSharedValue(initialY);
55
+ const offset = useSharedValue({ x: 0, y: 0 });
56
+
57
+ const drag = Gesture.Pan()
58
+ .minDistance(5)
59
+ .onStart(() => {
60
+ offset.value = { x: translateX.value, y: translateY.value };
61
+ })
62
+ .onUpdate((event) => {
63
+ translateX.value = offset.value.x + event.translationX;
64
+ translateY.value = offset.value.y + event.translationY;
65
+ })
66
+ .onEnd(() => {
67
+ runOnJS(onDragEnd)(translateX.value, translateY.value);
68
+ });
69
+
70
+ const tap = Gesture.Tap()
71
+ .maxDistance(5)
72
+ .onEnd(() => {
73
+ runOnJS(onPress)();
74
+ });
75
+
76
+ const gesture = Gesture.Exclusive(drag, tap);
77
+
78
+ const animatedStyle = useAnimatedStyle(() => {
79
+ return {
80
+ transform: [
81
+ { translateX: translateX.value },
82
+ { translateY: translateY.value },
83
+ { rotate: `${rotation}deg` },
84
+ { scale: scale },
85
+ ],
86
+ opacity: opacity,
87
+ zIndex: isSelected ? 100 : 10,
88
+ };
89
+ });
90
+
91
+ return (
92
+ <GestureDetector gesture={gesture}>
93
+ <Animated.View
94
+ style={[
95
+ styles.container,
96
+ animatedStyle,
97
+ { position: "absolute", left: 0, top: 0 },
98
+ ]}
99
+ >
100
+ <View
101
+ style={[
102
+ {
103
+ backgroundColor: backgroundColor,
104
+ paddingHorizontal: 8,
105
+ paddingVertical: 4,
106
+ borderRadius: 4,
107
+ },
108
+ isSelected && {
109
+ borderWidth: 2,
110
+ borderColor: tokens.colors.primary,
111
+ borderStyle: "dashed",
112
+ backgroundColor:
113
+ backgroundColor === "transparent"
114
+ ? `${tokens.colors.primary}10`
115
+ : backgroundColor,
116
+ },
117
+ ]}
118
+ >
119
+ <AtomicText
120
+ style={{
121
+ fontSize: fontSize,
122
+ fontFamily: fontFamily,
123
+ fontWeight: "900",
124
+ textAlign: textAlign,
125
+ textTransform: "uppercase",
126
+ color: color,
127
+ minWidth: text ? undefined : 100,
128
+ minHeight: text ? undefined : 40,
129
+ }}
130
+ >
131
+ {text}
132
+ </AtomicText>
133
+ </View>
134
+ </Animated.View>
135
+ </GestureDetector>
136
+ );
137
+ };
138
+
139
+ const styles = StyleSheet.create({
140
+ container: {
141
+ zIndex: 10,
142
+ },
143
+ });
@@ -0,0 +1,80 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { Image } from "expo-image";
4
+ import { DraggableText } from "./DraggableText";
5
+ import { DraggableSticker } from "./DraggableSticker";
6
+ import { Layer, TextLayer, StickerLayer } from "../types";
7
+
8
+ interface EditorCanvasProps {
9
+ imageUrl: string;
10
+ layers: Layer[];
11
+ activeLayerId: string | null;
12
+ onLayerTap: (layerId: string) => void;
13
+ onLayerMove: (layerId: string, x: number, y: number) => void;
14
+ styles: {
15
+ canvas: object;
16
+ canvasImage: object;
17
+ };
18
+ }
19
+
20
+ export const EditorCanvas: React.FC<EditorCanvasProps> = ({
21
+ imageUrl,
22
+ layers,
23
+ activeLayerId,
24
+ onLayerTap,
25
+ onLayerMove,
26
+ styles,
27
+ }) => {
28
+ return (
29
+ <View style={styles.canvas}>
30
+ <Image
31
+ source={{ uri: imageUrl }}
32
+ style={styles.canvasImage}
33
+ contentFit="cover"
34
+ />
35
+ {layers.map((layer) => {
36
+ if (layer.type === "text") {
37
+ const textLayer = layer as TextLayer;
38
+ return (
39
+ <DraggableText
40
+ key={layer.id}
41
+ text={textLayer.text || "Tap to edit"}
42
+ color={textLayer.color}
43
+ fontSize={textLayer.fontSize}
44
+ fontFamily={textLayer.fontFamily}
45
+ textAlign={textLayer.textAlign}
46
+ rotation={textLayer.rotation}
47
+ scale={textLayer.scale}
48
+ opacity={textLayer.opacity}
49
+ backgroundColor={textLayer.backgroundColor}
50
+ strokeColor={textLayer.strokeColor}
51
+ strokeWidth={textLayer.strokeWidth}
52
+ initialX={textLayer.x}
53
+ initialY={textLayer.y}
54
+ onDragEnd={(x, y) => onLayerMove(layer.id, x, y)}
55
+ onPress={() => onLayerTap(layer.id)}
56
+ isSelected={activeLayerId === layer.id}
57
+ />
58
+ );
59
+ } else if (layer.type === "sticker") {
60
+ const stickerLayer = layer as StickerLayer;
61
+ return (
62
+ <DraggableSticker
63
+ key={layer.id}
64
+ uri={stickerLayer.uri}
65
+ initialX={stickerLayer.x}
66
+ initialY={stickerLayer.y}
67
+ rotation={stickerLayer.rotation}
68
+ scale={stickerLayer.scale}
69
+ opacity={stickerLayer.opacity}
70
+ onDragEnd={(x, y) => onLayerMove(layer.id, x, y)}
71
+ onPress={() => onLayerTap(layer.id)}
72
+ isSelected={activeLayerId === layer.id}
73
+ />
74
+ );
75
+ }
76
+ return null;
77
+ })}
78
+ </View>
79
+ );
80
+ };