@umituz/react-native-photo-editor 2.0.21 → 2.0.23

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 (30) hide show
  1. package/ARCHITECTURE.md +104 -0
  2. package/MIGRATION.md +100 -0
  3. package/package.json +1 -5
  4. package/src/PhotoEditor.tsx +56 -44
  5. package/src/application/hooks/useEditor.ts +67 -0
  6. package/src/application/hooks/useEditorUI.ts +145 -0
  7. package/src/application/stores/EditorStore.ts +137 -0
  8. package/src/constants.ts +5 -52
  9. package/src/domain/entities/Filters.ts +72 -0
  10. package/src/domain/entities/Layer.ts +126 -0
  11. package/src/domain/entities/Transform.ts +55 -0
  12. package/src/domain/services/HistoryService.ts +60 -0
  13. package/src/domain/services/LayerService.ts +105 -0
  14. package/src/index.ts +25 -5
  15. package/src/infrastructure/gesture/types.ts +27 -0
  16. package/src/infrastructure/gesture/useTransformGesture.ts +136 -0
  17. package/src/infrastructure/history/HistoryManager.ts +38 -0
  18. package/src/presentation/components/DraggableLayer.tsx +114 -0
  19. package/src/presentation/components/EditorCanvas.tsx +90 -0
  20. package/src/presentation/components/EditorToolbar.tsx +192 -0
  21. package/src/presentation/components/FontControls.tsx +99 -0
  22. package/src/presentation/components/sheets/AIMagicSheet.tsx +99 -0
  23. package/src/presentation/components/sheets/AdjustmentsSheet.tsx +113 -0
  24. package/src/presentation/components/sheets/FilterSheet.tsx +128 -0
  25. package/src/presentation/components/sheets/LayerManager.tsx +151 -0
  26. package/src/presentation/components/sheets/StickerPicker.tsx +67 -0
  27. package/src/presentation/components/sheets/TextEditorSheet.tsx +159 -0
  28. package/src/presentation/components/ui/ColorPicker.tsx +78 -0
  29. package/src/presentation/components/ui/Slider.tsx +116 -0
  30. package/src/types.ts +13 -58
@@ -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";
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Filter Sheet Component
3
+ * Bottom sheet for selecting preset filters
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, 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
+
11
+ export interface FilterOption {
12
+ id: string;
13
+ name: string;
14
+ icon: string;
15
+ filters: Record<string, number>;
16
+ }
17
+
18
+ interface FilterSheetProps {
19
+ selectedFilter: string;
20
+ onSelectFilter: (option: FilterOption) => void;
21
+ filters?: FilterOption[];
22
+ }
23
+
24
+ const DEFAULT_FILTERS: FilterOption[] = [
25
+ {
26
+ id: "none",
27
+ name: "None",
28
+ icon: "close",
29
+ filters: { brightness: 1, contrast: 1, saturation: 1, sepia: 0, grayscale: 0 },
30
+ },
31
+ {
32
+ id: "sepia",
33
+ name: "Sepia",
34
+ icon: "brush",
35
+ filters: { sepia: 0.7, saturation: 0.8 },
36
+ },
37
+ {
38
+ id: "grayscale",
39
+ name: "B&W",
40
+ icon: "swap-horizontal",
41
+ filters: { grayscale: 1, saturation: 0 },
42
+ },
43
+ {
44
+ id: "vintage",
45
+ name: "Vintage",
46
+ icon: "flash",
47
+ filters: { sepia: 0.3, contrast: 1.1, brightness: 0.9 },
48
+ },
49
+ {
50
+ id: "warm",
51
+ name: "Warm",
52
+ icon: "sparkles",
53
+ filters: { brightness: 1.05, saturation: 1.2 },
54
+ },
55
+ {
56
+ id: "cool",
57
+ name: "Cool",
58
+ icon: "image",
59
+ filters: { contrast: 1.05, brightness: 1.02, saturation: 0.85 },
60
+ },
61
+ ];
62
+
63
+ export const FilterSheet = memo<FilterSheetProps>(({
64
+ selectedFilter,
65
+ onSelectFilter,
66
+ filters = DEFAULT_FILTERS,
67
+ }) => {
68
+ const tokens = useAppDesignTokens();
69
+
70
+ return (
71
+ <View style={styles.container}>
72
+ <AtomicText type="headlineSmall">Filters</AtomicText>
73
+ <View style={styles.grid}>
74
+ {filters.map((f) => {
75
+ const isActive = selectedFilter === f.id;
76
+ return (
77
+ <TouchableOpacity
78
+ key={f.id}
79
+ style={[
80
+ styles.filter,
81
+ {
82
+ backgroundColor: tokens.colors.surfaceVariant,
83
+ borderColor: isActive ? tokens.colors.primary : "transparent",
84
+ },
85
+ ]}
86
+ onPress={() => onSelectFilter(f)}
87
+ accessibilityLabel={f.name}
88
+ accessibilityRole="button"
89
+ accessibilityState={{ selected: isActive }}
90
+ >
91
+ <AtomicIcon
92
+ name={f.icon as "close"}
93
+ size="lg"
94
+ color={isActive ? "primary" : "textSecondary"}
95
+ />
96
+ <AtomicText
97
+ type="labelSmall"
98
+ color={isActive ? "primary" : "textSecondary"}
99
+ >
100
+ {f.name}
101
+ </AtomicText>
102
+ </TouchableOpacity>
103
+ );
104
+ })}
105
+ </View>
106
+ </View>
107
+ );
108
+ });
109
+
110
+ FilterSheet.displayName = "FilterSheet";
111
+
112
+ const styles = StyleSheet.create({
113
+ container: { padding: 16, gap: 16 },
114
+ grid: {
115
+ flexDirection: "row",
116
+ flexWrap: "wrap",
117
+ gap: 12,
118
+ },
119
+ filter: {
120
+ width: 75,
121
+ height: 75,
122
+ borderRadius: 8,
123
+ alignItems: "center",
124
+ justifyContent: "center",
125
+ borderWidth: 2,
126
+ gap: 4,
127
+ },
128
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Layer Manager Sheet Component
3
+ * Bottom sheet for managing layers
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, ScrollView, 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 { Layer, isTextLayer } from "../../../domain/entities/Layer";
11
+
12
+ interface LayerManagerProps {
13
+ layers: Layer[];
14
+ activeLayerId: string | null;
15
+ onSelectLayer: (id: string) => void;
16
+ onDeleteLayer: (id: string) => void;
17
+ onDuplicateLayer?: (id: string) => void;
18
+ onMoveLayerUp?: (id: string) => void;
19
+ onMoveLayerDown?: (id: string) => void;
20
+ t: (key: string) => string;
21
+ }
22
+
23
+ export const LayerManager = memo<LayerManagerProps>(({
24
+ layers,
25
+ activeLayerId,
26
+ onSelectLayer,
27
+ onDeleteLayer,
28
+ onDuplicateLayer,
29
+ onMoveLayerUp,
30
+ onMoveLayerDown,
31
+ t,
32
+ }) => {
33
+ const tokens = useAppDesignTokens();
34
+ const sortedLayers = [...layers].reverse();
35
+
36
+ return (
37
+ <View style={{ padding: tokens.spacing.md, gap: tokens.spacing.md }}>
38
+ <AtomicText type="headlineSmall">Layers</AtomicText>
39
+ <ScrollView showsVerticalScrollIndicator={false}>
40
+ {sortedLayers.length === 0 ? (
41
+ <AtomicText
42
+ color="textSecondary"
43
+ style={{ textAlign: "center", padding: tokens.spacing.xl }}
44
+ >
45
+ No layers yet
46
+ </AtomicText>
47
+ ) : (
48
+ sortedLayers.map((layer, idx) => {
49
+ const isActive = activeLayerId === layer.id;
50
+ const label = isTextLayer(layer)
51
+ ? layer.text || t("photo_editor.untitled") || "Untitled"
52
+ : "Sticker";
53
+ const isTop = idx === 0;
54
+ const isBottom = idx === sortedLayers.length - 1;
55
+
56
+ return (
57
+ <TouchableOpacity
58
+ key={layer.id}
59
+ style={{
60
+ flexDirection: "row",
61
+ alignItems: "center",
62
+ padding: tokens.spacing.sm,
63
+ backgroundColor: tokens.colors.surfaceVariant,
64
+ borderRadius: tokens.borders.radius.md,
65
+ marginBottom: tokens.spacing.xs,
66
+ borderWidth: 2,
67
+ borderColor: isActive ? tokens.colors.primary : "transparent",
68
+ backgroundColor: isActive ? tokens.colors.primary + "10" : tokens.colors.surfaceVariant,
69
+ }}
70
+ onPress={() => onSelectLayer(layer.id)}
71
+ accessibilityLabel={`${layer.type} layer: ${label}`}
72
+ accessibilityRole="button"
73
+ accessibilityState={{ selected: isActive }}
74
+ >
75
+ <AtomicIcon
76
+ name={layer.type === "text" ? "edit" : "image"}
77
+ size="sm"
78
+ color={isActive ? "primary" : "textSecondary"}
79
+ />
80
+ <View style={{ flex: 1, marginLeft: tokens.spacing.sm }}>
81
+ <AtomicText type="labelSmall" color="textSecondary">
82
+ {layer.type.toUpperCase()}
83
+ </AtomicText>
84
+ <AtomicText fontWeight="bold" numberOfLines={1}>
85
+ {label}
86
+ </AtomicText>
87
+ </View>
88
+
89
+ <View style={{ flexDirection: "row", alignItems: "center", gap: tokens.spacing.xs }}>
90
+ {onMoveLayerUp && (
91
+ <TouchableOpacity
92
+ style={{ padding: tokens.spacing.xs, borderRadius: tokens.borders.radius.sm }}
93
+ onPress={() => onMoveLayerUp(layer.id)}
94
+ disabled={isTop}
95
+ accessibilityLabel="Move layer up"
96
+ accessibilityRole="button"
97
+ >
98
+ <AtomicIcon
99
+ name="chevron-forward"
100
+ size="sm"
101
+ color={isTop ? "textSecondary" : "textPrimary"}
102
+ />
103
+ </TouchableOpacity>
104
+ )}
105
+
106
+ {onMoveLayerDown && (
107
+ <TouchableOpacity
108
+ style={{ padding: tokens.spacing.xs, borderRadius: tokens.borders.radius.sm }}
109
+ onPress={() => onMoveLayerDown(layer.id)}
110
+ disabled={isBottom}
111
+ accessibilityLabel="Move layer down"
112
+ accessibilityRole="button"
113
+ >
114
+ <AtomicIcon
115
+ name="chevron-back"
116
+ size="sm"
117
+ color={isBottom ? "textSecondary" : "textPrimary"}
118
+ />
119
+ </TouchableOpacity>
120
+ )}
121
+
122
+ {onDuplicateLayer && (
123
+ <TouchableOpacity
124
+ style={{ padding: tokens.spacing.xs, borderRadius: tokens.borders.radius.sm }}
125
+ onPress={() => onDuplicateLayer(layer.id)}
126
+ accessibilityLabel={`Duplicate ${label}`}
127
+ accessibilityRole="button"
128
+ >
129
+ <AtomicIcon name="copy" size="sm" color="textSecondary" />
130
+ </TouchableOpacity>
131
+ )}
132
+
133
+ <TouchableOpacity
134
+ style={{ padding: tokens.spacing.xs, borderRadius: tokens.borders.radius.sm }}
135
+ onPress={() => onDeleteLayer(layer.id)}
136
+ accessibilityLabel={`Delete ${label}`}
137
+ accessibilityRole="button"
138
+ >
139
+ <AtomicIcon name="trash-outline" size="sm" color="error" />
140
+ </TouchableOpacity>
141
+ </View>
142
+ </TouchableOpacity>
143
+ );
144
+ })
145
+ )}
146
+ </ScrollView>
147
+ </View>
148
+ );
149
+ });
150
+
151
+ LayerManager.displayName = "LayerManager";
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Sticker Picker Sheet Component
3
+ * Bottom sheet for selecting stickers
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { View, 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
+
11
+ const DEFAULT_STICKERS = [
12
+ "😀", "😂", "🤣", "😍", "🥰", "😎", "🤯", "🥳", "😤", "💀",
13
+ "🔥", "❤️", "💯", "✨", "🎉", "🤡", "👀", "🙌", "👏", "💪",
14
+ "🤝", "🙈", "🐶", "🐱", "🦊", "🐸", "🌟", "⭐", "🌈", "☀️",
15
+ "🌙", "💫",
16
+ ];
17
+
18
+ interface StickerPickerProps {
19
+ onSelectSticker: (sticker: string) => void;
20
+ stickers?: readonly string[];
21
+ }
22
+
23
+ export const StickerPicker = memo<StickerPickerProps>(({
24
+ onSelectSticker,
25
+ stickers = DEFAULT_STICKERS,
26
+ }) => {
27
+ const tokens = useAppDesignTokens();
28
+
29
+ return (
30
+ <View style={styles.container}>
31
+ <AtomicText type="headlineSmall" style={{ marginBottom: tokens.spacing.md }}>
32
+ Stickers
33
+ </AtomicText>
34
+ <View style={styles.grid}>
35
+ {stickers.map((sticker) => (
36
+ <TouchableOpacity
37
+ key={sticker}
38
+ style={{
39
+ width: 60,
40
+ height: 60,
41
+ borderRadius: tokens.borders.radius.md,
42
+ backgroundColor: tokens.colors.surfaceVariant,
43
+ alignItems: "center",
44
+ justifyContent: "center",
45
+ }}
46
+ onPress={() => onSelectSticker(sticker)}
47
+ accessibilityLabel={`Sticker ${sticker}`}
48
+ accessibilityRole="button"
49
+ >
50
+ <AtomicText style={{ fontSize: 36 }}>{sticker}</AtomicText>
51
+ </TouchableOpacity>
52
+ ))}
53
+ </View>
54
+ </View>
55
+ );
56
+ });
57
+
58
+ StickerPicker.displayName = "StickerPicker";
59
+
60
+ const styles = StyleSheet.create({
61
+ container: { padding: 16, gap: 16 },
62
+ grid: {
63
+ flexDirection: "row",
64
+ flexWrap: "wrap",
65
+ gap: 12,
66
+ },
67
+ });