@umituz/react-native-photo-editor 2.0.22 → 2.0.24

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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/PhotoEditor.tsx +56 -44
  3. package/src/application/hooks/useEditor.ts +67 -0
  4. package/src/application/hooks/useEditorUI.ts +145 -0
  5. package/src/application/stores/EditorStore.ts +137 -0
  6. package/src/constants.ts +5 -52
  7. package/src/domain/entities/Filters.ts +72 -0
  8. package/src/domain/entities/Layer.ts +126 -0
  9. package/src/domain/entities/Transform.ts +55 -0
  10. package/src/domain/services/HistoryService.ts +60 -0
  11. package/src/domain/services/LayerService.ts +105 -0
  12. package/src/index.ts +25 -5
  13. package/src/infrastructure/gesture/types.ts +27 -0
  14. package/src/infrastructure/gesture/useTransformGesture.ts +136 -0
  15. package/src/infrastructure/history/HistoryManager.ts +38 -0
  16. package/src/presentation/components/DraggableLayer.tsx +114 -0
  17. package/src/presentation/components/EditorCanvas.tsx +90 -0
  18. package/src/presentation/components/EditorToolbar.tsx +192 -0
  19. package/src/presentation/components/FontControls.tsx +99 -0
  20. package/src/presentation/components/sheets/AIMagicSheet.tsx +99 -0
  21. package/src/presentation/components/sheets/AdjustmentsSheet.tsx +113 -0
  22. package/src/presentation/components/sheets/FilterSheet.tsx +128 -0
  23. package/src/presentation/components/sheets/LayerManager.tsx +151 -0
  24. package/src/presentation/components/sheets/StickerPicker.tsx +67 -0
  25. package/src/presentation/components/sheets/TextEditorSheet.tsx +159 -0
  26. package/src/presentation/components/ui/ColorPicker.tsx +78 -0
  27. package/src/presentation/components/ui/Slider.tsx +116 -0
  28. package/src/types.ts +13 -58
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Editor Canvas Component
3
+ * Renders the image and all layers
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, StyleSheet } from "react-native";
8
+ import { Image } from "expo-image";
9
+ import { DraggableLayer } from "./DraggableLayer";
10
+ import { Layer } from "../../domain/entities/Layer";
11
+ import type { FilterValues } from "../../domain/entities/Filters";
12
+
13
+ interface EditorCanvasProps {
14
+ imageUrl: string;
15
+ layers: Layer[];
16
+ activeLayerId: string | null;
17
+ filters: FilterValues;
18
+ onLayerTap: (layerId: string) => void;
19
+ onLayerTransform: (layerId: string, transform: { x: number; y: number; scale: number; rotation: number }) => void;
20
+ style?: any;
21
+ }
22
+
23
+ export const EditorCanvas = memo<EditorCanvasProps>(({
24
+ imageUrl,
25
+ layers,
26
+ activeLayerId,
27
+ filters,
28
+ onLayerTap,
29
+ onLayerTransform,
30
+ style,
31
+ }) => {
32
+ const brightnessOverlay = createBrightnessOverlay(filters.brightness);
33
+
34
+ return (
35
+ <View style={[styles.canvas, style]}>
36
+ <Image
37
+ source={{ uri: imageUrl }}
38
+ style={styles.canvasImage}
39
+ contentFit="cover"
40
+ />
41
+
42
+ {brightnessOverlay && (
43
+ <View
44
+ style={[
45
+ StyleSheet.absoluteFill,
46
+ {
47
+ backgroundColor: brightnessOverlay.color,
48
+ opacity: brightnessOverlay.opacity,
49
+ },
50
+ ]}
51
+ pointerEvents="none"
52
+ />
53
+ )}
54
+
55
+ {layers.map((layer) => (
56
+ <DraggableLayer
57
+ key={layer.id}
58
+ layer={layer}
59
+ isSelected={activeLayerId === layer.id}
60
+ onPress={() => onLayerTap(layer.id)}
61
+ onTransformEnd={(transform) => onLayerTransform(layer.id, transform)}
62
+ />
63
+ ))}
64
+ </View>
65
+ );
66
+ });
67
+
68
+ EditorCanvas.displayName = "EditorCanvas";
69
+
70
+ function createBrightnessOverlay(brightness: number = 1) {
71
+ if (brightness < 1) {
72
+ return { color: "black", opacity: Math.min(0.6, 1 - brightness) };
73
+ }
74
+ if (brightness > 1) {
75
+ return { color: "white", opacity: Math.min(0.4, brightness - 1) };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ const styles = StyleSheet.create({
81
+ canvas: {
82
+ position: "relative",
83
+ width: "100%",
84
+ aspectRatio: 1,
85
+ },
86
+ canvasImage: {
87
+ width: "100%",
88
+ height: "100%",
89
+ },
90
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Editor Toolbar Component
3
+ * Main toolbar with action buttons
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, TouchableOpacity, ScrollView } from "react-native";
8
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
10
+
11
+ interface ToolButtonProps {
12
+ icon: string;
13
+ label: string;
14
+ onPress: () => void;
15
+ isActive?: boolean;
16
+ disabled?: boolean;
17
+ isPrimary?: boolean;
18
+ }
19
+
20
+ const ToolButton = memo<ToolButtonProps>(({ icon, label, onPress, isActive, disabled, isPrimary }) => {
21
+ const tokens = useAppDesignTokens();
22
+
23
+ if (isPrimary) {
24
+ return (
25
+ <TouchableOpacity
26
+ style={{
27
+ width: 56,
28
+ height: 56,
29
+ borderRadius: 28,
30
+ backgroundColor: tokens.colors.primary,
31
+ alignItems: "center",
32
+ justifyContent: "center",
33
+ elevation: 4,
34
+ shadowColor: "#000",
35
+ shadowOffset: { width: 0, height: 2 },
36
+ shadowOpacity: 0.25,
37
+ shadowRadius: 4,
38
+ }}
39
+ onPress={onPress}
40
+ accessibilityLabel={label}
41
+ accessibilityRole="button"
42
+ >
43
+ <AtomicIcon name={icon as "sparkles"} size="lg" customColor={tokens.colors.onPrimary} />
44
+ </TouchableOpacity>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <TouchableOpacity
50
+ style={{
51
+ alignItems: "center",
52
+ gap: tokens.spacing.xs,
53
+ opacity: disabled ? 0.5 : 1,
54
+ }}
55
+ onPress={onPress}
56
+ disabled={disabled}
57
+ accessibilityLabel={label}
58
+ accessibilityRole="button"
59
+ accessibilityState={{ selected: isActive, disabled }}
60
+ >
61
+ <AtomicIcon
62
+ name={icon as "edit"}
63
+ size="md"
64
+ color={disabled ? "textSecondary" : isActive ? "primary" : "textSecondary"}
65
+ />
66
+ <AtomicText
67
+ type="labelSmall"
68
+ color={disabled ? "textSecondary" : isActive ? "primary" : "textSecondary"}
69
+ >
70
+ {label}
71
+ </AtomicText>
72
+ </TouchableOpacity>
73
+ );
74
+ });
75
+
76
+ ToolButton.displayName = "ToolButton";
77
+
78
+ export interface EditorToolbarProps {
79
+ onAddText: () => void;
80
+ onAddSticker?: () => void;
81
+ onOpenFilters?: () => void;
82
+ onOpenAdjustments?: () => void;
83
+ onOpenLayers: () => void;
84
+ onAIMagic?: () => void;
85
+ onUndo?: () => void;
86
+ onRedo?: () => void;
87
+ canUndo?: boolean;
88
+ canRedo?: boolean;
89
+ t: (key: string) => string;
90
+ }
91
+
92
+ export const EditorToolbar = memo<EditorToolbarProps>(({
93
+ onAddText,
94
+ onAddSticker,
95
+ onOpenFilters,
96
+ onOpenAdjustments,
97
+ onOpenLayers,
98
+ onAIMagic,
99
+ onUndo,
100
+ onRedo,
101
+ canUndo = false,
102
+ canRedo = false,
103
+ t,
104
+ }) => {
105
+ const tokens = useAppDesignTokens();
106
+
107
+ return (
108
+ <View
109
+ style={{
110
+ flexDirection: "row",
111
+ alignItems: "center",
112
+ paddingVertical: tokens.spacing.md,
113
+ paddingHorizontal: tokens.spacing.sm,
114
+ backgroundColor: tokens.colors.surface,
115
+ borderTopWidth: 1,
116
+ borderTopColor: tokens.colors.border,
117
+ gap: tokens.spacing.sm,
118
+ }}
119
+ >
120
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ flex: 1 }}>
121
+ <View style={{ flexDirection: "row", alignItems: "center", gap: tokens.spacing.md }}>
122
+ {onUndo && (
123
+ <ToolButton
124
+ icon="arrow-back"
125
+ label={t("photo_editor.undo") || "Undo"}
126
+ onPress={onUndo}
127
+ disabled={!canUndo}
128
+ />
129
+ )}
130
+
131
+ <ToolButton
132
+ icon="edit"
133
+ label={t("photo_editor.text") || "Text"}
134
+ onPress={onAddText}
135
+ />
136
+
137
+ {onAddSticker && (
138
+ <ToolButton
139
+ icon="sparkles"
140
+ label={t("photo_editor.sticker") || "Sticker"}
141
+ onPress={onAddSticker}
142
+ />
143
+ )}
144
+
145
+ {onOpenAdjustments && (
146
+ <ToolButton
147
+ icon="flash"
148
+ label={t("photo_editor.adjust") || "Adjust"}
149
+ onPress={onOpenAdjustments}
150
+ />
151
+ )}
152
+
153
+ {onOpenFilters && (
154
+ <ToolButton
155
+ icon="brush"
156
+ label={t("photo_editor.filters") || "Filters"}
157
+ onPress={onOpenFilters}
158
+ />
159
+ )}
160
+
161
+ <ToolButton
162
+ icon="copy"
163
+ label={t("photo_editor.layers") || "Layers"}
164
+ onPress={onOpenLayers}
165
+ />
166
+
167
+ {onRedo && (
168
+ <ToolButton
169
+ icon="chevron-forward"
170
+ label={t("photo_editor.redo") || "Redo"}
171
+ onPress={onRedo}
172
+ disabled={!canRedo}
173
+ />
174
+ )}
175
+ </View>
176
+ </ScrollView>
177
+
178
+ {onAIMagic && (
179
+ <View style={{ marginLeft: tokens.spacing.sm }}>
180
+ <ToolButton
181
+ icon="sparkles"
182
+ label="AI"
183
+ onPress={onAIMagic}
184
+ isPrimary
185
+ />
186
+ </View>
187
+ )}
188
+ </View>
189
+ );
190
+ });
191
+
192
+ EditorToolbar.displayName = "EditorToolbar";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Font Controls Component
3
+ * Font family and size controls
4
+ */
5
+
6
+ import React, { memo } 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 { Slider } from "./ui/Slider";
11
+
12
+ const DEFAULT_FONTS = [
13
+ "System",
14
+ "Impact",
15
+ "Comic",
16
+ "Serif",
17
+ "Retro",
18
+ ];
19
+
20
+ interface FontControlsProps {
21
+ fontSize: number;
22
+ selectedFont: string;
23
+ fonts?: readonly string[];
24
+ onFontSizeChange: (size: number) => void;
25
+ onFontSelect: (font: string) => void;
26
+ style?: any;
27
+ }
28
+
29
+ export const FontControls = memo<FontControlsProps>(({
30
+ fontSize,
31
+ selectedFont,
32
+ fonts = DEFAULT_FONTS,
33
+ onFontSizeChange,
34
+ onFontSelect,
35
+ style,
36
+ }) => {
37
+ const tokens = useAppDesignTokens();
38
+
39
+ return (
40
+ <View style={[styles.container, style]}>
41
+ <Slider
42
+ label="Font Size"
43
+ value={fontSize}
44
+ min={12}
45
+ max={120}
46
+ step={1}
47
+ onValueChange={onFontSizeChange}
48
+ formatValue={(v) => `${Math.round(v)}px`}
49
+ />
50
+
51
+ <ScrollView
52
+ horizontal
53
+ showsHorizontalScrollIndicator={false}
54
+ style={{ marginTop: tokens.spacing.sm }}
55
+ contentContainerStyle={{ gap: tokens.spacing.sm, paddingHorizontal: tokens.spacing.sm }}
56
+ >
57
+ {fonts.map((font) => (
58
+ <TouchableOpacity
59
+ key={font}
60
+ style={[
61
+ styles.fontButton,
62
+ {
63
+ backgroundColor: tokens.colors.surfaceVariant,
64
+ borderWidth: selectedFont === font ? 2 : 1,
65
+ borderColor: selectedFont === font ? tokens.colors.primary : tokens.colors.border,
66
+ },
67
+ ]}
68
+ onPress={() => onFontSelect(font)}
69
+ accessibilityLabel={`Font ${font}`}
70
+ accessibilityRole="button"
71
+ accessibilityState={{ selected: selectedFont === font }}
72
+ >
73
+ <AtomicText
74
+ style={{
75
+ fontFamily: font === "System" ? undefined : font,
76
+ color: selectedFont === font ? tokens.colors.primary : tokens.colors.textPrimary,
77
+ }}
78
+ >
79
+ {font}
80
+ </AtomicText>
81
+ </TouchableOpacity>
82
+ ))}
83
+ </ScrollView>
84
+ </View>
85
+ );
86
+ });
87
+
88
+ FontControls.displayName = "FontControls";
89
+
90
+ const styles = StyleSheet.create({
91
+ container: { gap: 8 },
92
+ fontButton: {
93
+ paddingHorizontal: 16,
94
+ paddingVertical: 8,
95
+ borderRadius: 8,
96
+ minWidth: 80,
97
+ alignItems: "center",
98
+ },
99
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * AI Magic Sheet Component
3
+ * Bottom sheet for AI-powered features
4
+ */
5
+
6
+ import React, { memo, useState } from "react";
7
+ import { View, TouchableOpacity, StyleSheet } from "react-native";
8
+ import { AtomicText, AtomicButton } from "@umituz/react-native-design-system/atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
10
+
11
+ const AI_STYLES = [
12
+ { id: "viral", label: "✨ Viral", desc: "Catchy & shareable" },
13
+ { id: "funny", label: "😂 Funny", desc: "Humor that connects" },
14
+ { id: "savage", label: "🔥 Savage", desc: "Bold & edgy" },
15
+ { id: "wholesome", label: "💕 Wholesome", desc: "Warm & positive" },
16
+ { id: "sarcastic", label: "😏 Sarcastic", desc: "Witty & ironic" },
17
+ { id: "relatable", label: "🎯 Relatable", desc: "Everyone gets it" },
18
+ ];
19
+
20
+ interface AIMagicSheetProps {
21
+ onGenerateCaption: (style: string) => Promise<string> | void;
22
+ }
23
+
24
+ export const AIMagicSheet = memo<AIMagicSheetProps>(({ onGenerateCaption }) => {
25
+ const tokens = useAppDesignTokens();
26
+ const [selectedStyle, setSelectedStyle] = useState<string>("viral");
27
+ const [isLoading, setIsLoading] = useState(false);
28
+
29
+ const handleGenerate = async () => {
30
+ setIsLoading(true);
31
+ try {
32
+ await onGenerateCaption(selectedStyle);
33
+ } finally {
34
+ setIsLoading(false);
35
+ }
36
+ };
37
+
38
+ return (
39
+ <View style={styles.container}>
40
+ <AtomicText type="headlineSmall">AI Magic ✨</AtomicText>
41
+ <AtomicText type="bodyMedium" color="textSecondary">
42
+ Let AI create the perfect caption for your photo
43
+ </AtomicText>
44
+
45
+ <View style={styles.grid}>
46
+ {AI_STYLES.map((style) => (
47
+ <TouchableOpacity
48
+ key={style.id}
49
+ style={[
50
+ styles.styleCard,
51
+ {
52
+ backgroundColor: tokens.colors.surfaceVariant,
53
+ borderWidth: selectedStyle === style.id ? 2 : 1,
54
+ borderColor: selectedStyle === style.id ? tokens.colors.primary : tokens.colors.border,
55
+ },
56
+ ]}
57
+ onPress={() => setSelectedStyle(style.id)}
58
+ accessibilityLabel={style.label}
59
+ accessibilityRole="button"
60
+ accessibilityState={{ selected: selectedStyle === style.id }}
61
+ >
62
+ <AtomicText type="labelLarge" fontWeight="bold">
63
+ {style.label}
64
+ </AtomicText>
65
+ <AtomicText type="labelSmall" color="textSecondary">
66
+ {style.desc}
67
+ </AtomicText>
68
+ </TouchableOpacity>
69
+ ))}
70
+ </View>
71
+
72
+ <AtomicButton
73
+ variant="primary"
74
+ onPress={handleGenerate}
75
+ disabled={isLoading}
76
+ >
77
+ {isLoading ? "Generating..." : "Generate Caption"}
78
+ </AtomicButton>
79
+ </View>
80
+ );
81
+ });
82
+
83
+ AIMagicSheet.displayName = "AIMagicSheet";
84
+
85
+ const styles = StyleSheet.create({
86
+ container: { padding: 16, gap: 16 },
87
+ grid: {
88
+ flexDirection: "row",
89
+ flexWrap: "wrap",
90
+ gap: 12,
91
+ },
92
+ styleCard: {
93
+ flex: 1,
94
+ minWidth: "45%",
95
+ padding: 12,
96
+ borderRadius: 8,
97
+ gap: 4,
98
+ },
99
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Adjustments Sheet Component
3
+ * Bottom sheet for manual filter adjustments
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, TouchableOpacity } from "react-native";
8
+ import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
9
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
10
+ import { Slider } from "../ui/Slider";
11
+ import { DEFAULT_FILTERS } from "../../../domain/entities/Filters";
12
+
13
+ interface AdjustmentsSheetProps {
14
+ filters: Record<string, number>;
15
+ onFiltersChange: (filters: Record<string, number>) => void;
16
+ }
17
+
18
+ export const AdjustmentsSheet = memo<AdjustmentsSheetProps>(({
19
+ filters,
20
+ onFiltersChange,
21
+ }) => {
22
+ const tokens = useAppDesignTokens();
23
+
24
+ const update = (key: string, val: number) => {
25
+ onFiltersChange({ ...filters, [key]: val });
26
+ };
27
+
28
+ const handleReset = () => onFiltersChange(DEFAULT_FILTERS);
29
+
30
+ return (
31
+ <View style={{ padding: tokens.spacing.md, gap: tokens.spacing.lg }}>
32
+ <View
33
+ style={{
34
+ flexDirection: "row",
35
+ alignItems: "center",
36
+ justifyContent: "space-between",
37
+ }}
38
+ >
39
+ <View style={{ flexDirection: "row", alignItems: "center", gap: tokens.spacing.sm }}>
40
+ <AtomicIcon name="brush" size="md" color="primary" />
41
+ <AtomicText type="headlineSmall">Adjustments</AtomicText>
42
+ </View>
43
+ <TouchableOpacity
44
+ onPress={handleReset}
45
+ accessibilityLabel="Reset adjustments"
46
+ accessibilityRole="button"
47
+ style={{
48
+ paddingHorizontal: tokens.spacing.md,
49
+ paddingVertical: tokens.spacing.xs,
50
+ backgroundColor: tokens.colors.surfaceVariant,
51
+ borderRadius: tokens.borders.radius.sm,
52
+ }}
53
+ >
54
+ <AtomicText type="labelSmall" color="textSecondary">
55
+ Reset
56
+ </AtomicText>
57
+ </TouchableOpacity>
58
+ </View>
59
+
60
+ <Slider
61
+ label="Brightness"
62
+ value={filters.brightness ?? 1}
63
+ min={0.5}
64
+ max={2}
65
+ step={0.05}
66
+ onValueChange={(v) => update("brightness", v)}
67
+ formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
68
+ />
69
+
70
+ <Slider
71
+ label="Contrast"
72
+ value={filters.contrast ?? 1}
73
+ min={0.5}
74
+ max={2}
75
+ step={0.05}
76
+ onValueChange={(v) => update("contrast", v)}
77
+ formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
78
+ />
79
+
80
+ <Slider
81
+ label="Saturation"
82
+ value={filters.saturation ?? 1}
83
+ min={0}
84
+ max={2}
85
+ step={0.05}
86
+ onValueChange={(v) => update("saturation", v)}
87
+ formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
88
+ />
89
+
90
+ <Slider
91
+ label="Hue Rotate"
92
+ value={filters.hueRotate ?? 0}
93
+ min={0}
94
+ max={360}
95
+ step={1}
96
+ onValueChange={(v) => update("hueRotate", v)}
97
+ formatValue={(v) => `${Math.round(v)}°`}
98
+ />
99
+
100
+ <Slider
101
+ label="Sepia"
102
+ value={filters.sepia ?? 0}
103
+ min={0}
104
+ max={1}
105
+ step={0.05}
106
+ onValueChange={(v) => update("sepia", v)}
107
+ formatValue={(v) => `${Math.round(v * 100)}%`}
108
+ />
109
+ </View>
110
+ );
111
+ });
112
+
113
+ AdjustmentsSheet.displayName = "AdjustmentsSheet";