@umituz/react-native-photo-editor 1.1.2 → 2.0.2

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.
@@ -1,23 +1,48 @@
1
- import React from "react";
2
- import { View, TextInput, StyleSheet } from "react-native";
3
- import { AtomicText, useAppDesignTokens, AtomicButton } from "@umituz/react-native-design-system";
1
+ import React, { useMemo } from "react";
2
+ import { View, TextInput, TouchableOpacity, StyleSheet } from "react-native";
3
+ import { AtomicText, AtomicButton } from "@umituz/react-native-design-system/atoms";
4
+ import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
5
+ import { ColorPicker } from "./ColorPicker";
6
+ import type { TextAlign } from "../types";
4
7
 
5
8
  interface TextEditorSheetProps {
6
9
  value: string;
7
10
  onChange: (text: string) => void;
8
11
  onSave: () => void;
9
12
  t: (key: string) => string;
13
+ color?: string;
14
+ onColorChange?: (color: string) => void;
15
+ textAlign?: TextAlign;
16
+ onTextAlignChange?: (align: TextAlign) => void;
17
+ isBold?: boolean;
18
+ onBoldChange?: (bold: boolean) => void;
19
+ isItalic?: boolean;
20
+ onItalicChange?: (italic: boolean) => void;
10
21
  }
11
22
 
23
+ const ALIGN_OPTIONS: { value: TextAlign; icon: string }[] = [
24
+ { value: "left", icon: "«" },
25
+ { value: "center", icon: "≡" },
26
+ { value: "right", icon: "»" },
27
+ ];
28
+
12
29
  export const TextEditorSheet: React.FC<TextEditorSheetProps> = ({
13
30
  value,
14
31
  onChange,
15
32
  onSave,
16
33
  t,
34
+ color = "#FFFFFF",
35
+ onColorChange,
36
+ textAlign = "center",
37
+ onTextAlignChange,
38
+ isBold = false,
39
+ onBoldChange,
40
+ isItalic = false,
41
+ onItalicChange,
17
42
  }) => {
18
43
  const tokens = useAppDesignTokens();
19
44
 
20
- const styles = StyleSheet.create({
45
+ const styles = useMemo(() => StyleSheet.create({
21
46
  container: { padding: tokens.spacing.md, gap: tokens.spacing.md },
22
47
  input: {
23
48
  backgroundColor: tokens.colors.surfaceVariant,
@@ -26,26 +51,107 @@ export const TextEditorSheet: React.FC<TextEditorSheetProps> = ({
26
51
  fontSize: 18,
27
52
  color: tokens.colors.textPrimary,
28
53
  textAlign: "center",
29
- minHeight: 120,
54
+ minHeight: 90,
55
+ },
56
+ row: {
57
+ flexDirection: "row",
58
+ alignItems: "center",
59
+ gap: tokens.spacing.sm,
60
+ },
61
+ styleBtn: {
62
+ width: 44,
63
+ height: 44,
64
+ borderRadius: tokens.borders.radius.sm,
65
+ borderWidth: 1.5,
66
+ borderColor: tokens.colors.border,
67
+ alignItems: "center",
68
+ justifyContent: "center",
69
+ backgroundColor: tokens.colors.surfaceVariant,
70
+ },
71
+ styleBtnActive: {
72
+ borderColor: tokens.colors.primary,
73
+ backgroundColor: tokens.colors.primary + "20",
30
74
  },
31
- });
75
+ }), [tokens]);
32
76
 
33
77
  return (
34
78
  <View style={styles.container}>
35
- <AtomicText type="headlineSmall">{t("editor.add_text")}</AtomicText>
36
-
79
+ <AtomicText type="headlineSmall">{t("editor.add_text") || "Edit Text"}</AtomicText>
80
+
37
81
  <TextInput
38
82
  value={value}
39
83
  onChangeText={onChange}
40
- placeholder={t("editor.tap_to_edit")}
84
+ placeholder={t("editor.tap_to_edit") || "Enter text…"}
41
85
  placeholderTextColor={tokens.colors.textSecondary}
42
86
  style={styles.input}
43
87
  multiline
44
88
  autoFocus
45
89
  />
46
90
 
91
+ {/* Style row: Bold, Italic, Alignment */}
92
+ <View style={styles.row}>
93
+ {onBoldChange && (
94
+ <TouchableOpacity
95
+ style={[styles.styleBtn, isBold && styles.styleBtnActive]}
96
+ onPress={() => onBoldChange(!isBold)}
97
+ accessibilityLabel="Bold"
98
+ accessibilityRole="button"
99
+ accessibilityState={{ selected: isBold }}
100
+ >
101
+ <AtomicText fontWeight="bold" color={isBold ? "primary" : "textSecondary"}>
102
+ B
103
+ </AtomicText>
104
+ </TouchableOpacity>
105
+ )}
106
+
107
+ {onItalicChange && (
108
+ <TouchableOpacity
109
+ style={[styles.styleBtn, isItalic && styles.styleBtnActive]}
110
+ onPress={() => onItalicChange(!isItalic)}
111
+ accessibilityLabel="Italic"
112
+ accessibilityRole="button"
113
+ accessibilityState={{ selected: isItalic }}
114
+ >
115
+ <AtomicText
116
+ color={isItalic ? "primary" : "textSecondary"}
117
+ style={{ fontStyle: "italic" }}
118
+ >
119
+ I
120
+ </AtomicText>
121
+ </TouchableOpacity>
122
+ )}
123
+
124
+ {onTextAlignChange && (
125
+ <View style={[styles.row, { marginLeft: tokens.spacing.sm }]}>
126
+ {ALIGN_OPTIONS.map(({ value: align, icon }) => (
127
+ <TouchableOpacity
128
+ key={align}
129
+ style={[styles.styleBtn, textAlign === align && styles.styleBtnActive]}
130
+ onPress={() => onTextAlignChange(align)}
131
+ accessibilityLabel={`Align ${align}`}
132
+ accessibilityRole="button"
133
+ accessibilityState={{ selected: textAlign === align }}
134
+ >
135
+ <AtomicText color={textAlign === align ? "primary" : "textSecondary"}>
136
+ {icon}
137
+ </AtomicText>
138
+ </TouchableOpacity>
139
+ ))}
140
+ </View>
141
+ )}
142
+ </View>
143
+
144
+ {/* Color picker */}
145
+ {onColorChange && (
146
+ <ColorPicker
147
+ label="Text Color"
148
+ selectedColor={color}
149
+ onSelectColor={onColorChange}
150
+ />
151
+ )}
152
+
47
153
  <AtomicButton variant="primary" onPress={onSave}>
48
- {t("common.save")}
154
+ {t("common.save") || "Save"}
49
155
  </AtomicButton>
50
156
  </View>
51
157
  );
package/src/constants.ts CHANGED
@@ -3,60 +3,78 @@
3
3
  * These can be overridden via props
4
4
  */
5
5
 
6
- export const DEFAULT_FONTS = ["Impact", "Comic", "Serif", "Retro"] as const;
6
+ import type { ImageFilters } from "./types";
7
7
 
8
- export const DEFAULT_STICKERS = [
9
- "😀",
10
- "😂",
11
- "🤣",
12
- "😍",
13
- "🥰",
14
- "😎",
15
- "🤯",
16
- "🥳",
17
- "😤",
18
- "💀",
19
- "🔥",
20
- "❤️",
21
- "💯",
22
- "✨",
23
- "🎉",
24
- "🤡",
25
- "👀",
26
- "🙌",
27
- "👏",
28
- "💪",
29
- "🤝",
30
- "🙈",
31
- "🐶",
32
- "🐱",
33
- "🦊",
34
- "🐸",
35
- "🌟",
36
- "⭐",
37
- "🌈",
38
- "☀️",
39
- "🌙",
40
- "💫",
8
+ export const DEFAULT_TEXT_COLORS = [
9
+ "#FFFFFF", "#000000", "#888888", "#CCCCCC",
10
+ "#FF3B30", "#FF9500", "#FFCC00", "#FF2D55",
11
+ "#34C759", "#30B0C7", "#007AFF", "#5AC8FA",
12
+ "#5856D6", "#AF52DE", "#FF6B6B", "#FFD93D",
13
+ "#6BCB77", "#4D96FF", "#C77DFF", "#F72585",
14
+ ] as const;
15
+
16
+ export const DEFAULT_FONTS = [
17
+ "System",
18
+ "Impact",
19
+ "Comic",
20
+ "Serif",
21
+ "Retro",
41
22
  ] as const;
42
23
 
43
- export type FilterType = "none" | "sepia" | "grayscale" | "vintage" | "warm" | "cool";
24
+ export const DEFAULT_STICKERS = [
25
+ "😀", "😂", "🤣", "😍", "🥰", "😎", "🤯", "🥳", "😤", "💀",
26
+ "🔥", "❤️", "💯", "✨", "🎉", "🤡", "👀", "🙌", "👏", "💪",
27
+ "🤝", "🙈", "🐶", "🐱", "🦊", "🐸", "🌟", "⭐", "🌈", "☀️",
28
+ "🌙", "💫",
29
+ ] as const;
44
30
 
45
31
  export interface FilterOption {
46
- id: FilterType;
32
+ id: string;
47
33
  name: string;
34
+ /** Valid AtomicIcon name */
48
35
  icon: string;
49
- value: number;
36
+ /** Partial ImageFilters applied when this filter is selected */
37
+ filters: Partial<ImageFilters>;
50
38
  }
51
39
 
52
- export const DEFAULT_FILTERS = [
53
- { id: "none" as FilterType, name: "None", icon: "close-circle", value: 0 },
54
- { id: "sepia" as FilterType, name: "Sepia", icon: "color-palette", value: 0.5 },
55
- { id: "grayscale" as FilterType, name: "B&W", icon: "contrast", value: 1 },
56
- { id: "vintage" as FilterType, name: "Vintage", icon: "time", value: 0.7 },
57
- { id: "warm" as FilterType, name: "Warm", icon: "sunny", value: 0.3 },
58
- { id: "cool" as FilterType, name: "Cool", icon: "snow", value: 0.3 },
59
- ] as const;
40
+ export const DEFAULT_FILTERS: FilterOption[] = [
41
+ {
42
+ id: "none",
43
+ name: "None",
44
+ icon: "close",
45
+ filters: { brightness: 1, contrast: 1, saturation: 1, sepia: 0, grayscale: 0 },
46
+ },
47
+ {
48
+ id: "sepia",
49
+ name: "Sepia",
50
+ icon: "brush",
51
+ filters: { sepia: 0.7, saturation: 0.8 },
52
+ },
53
+ {
54
+ id: "grayscale",
55
+ name: "B&W",
56
+ icon: "swap-horizontal",
57
+ filters: { grayscale: 1, saturation: 0 },
58
+ },
59
+ {
60
+ id: "vintage",
61
+ name: "Vintage",
62
+ icon: "flash",
63
+ filters: { sepia: 0.3, contrast: 1.1, brightness: 0.9 },
64
+ },
65
+ {
66
+ id: "warm",
67
+ name: "Warm",
68
+ icon: "sparkles",
69
+ filters: { brightness: 1.05, saturation: 1.2 },
70
+ },
71
+ {
72
+ id: "cool",
73
+ name: "Cool",
74
+ icon: "image",
75
+ filters: { contrast: 1.05, brightness: 1.02, saturation: 0.85 },
76
+ },
77
+ ];
60
78
 
61
79
  export const DEFAULT_AI_STYLES = [
62
80
  { id: "viral", label: "✨ Viral", desc: "Catchy & shareable" },
@@ -9,57 +9,37 @@ export interface HistoryState<T> {
9
9
  }
10
10
 
11
11
  export class HistoryManager<T> {
12
- private maxHistory = 20;
12
+ private readonly maxHistory = 20;
13
13
 
14
14
  createInitialState(initialValue: T): HistoryState<T> {
15
- return {
16
- past: [],
17
- present: initialValue,
18
- future: [],
19
- };
15
+ return { past: [], present: initialValue, future: [] };
20
16
  }
21
17
 
22
18
  push(history: HistoryState<T>, newValue: T): HistoryState<T> {
23
- const { past, present } = history;
24
-
25
19
  return {
26
- past: [...past.slice(-this.maxHistory + 1), present],
20
+ past: [...history.past.slice(-this.maxHistory + 1), history.present],
27
21
  present: newValue,
28
22
  future: [],
29
23
  };
30
24
  }
31
25
 
32
26
  undo(history: HistoryState<T>): HistoryState<T> {
33
- const { past, present, future } = history;
34
-
35
- if (past.length === 0) {
36
- return history;
37
- }
38
-
39
- const previous = past[past.length - 1];
40
- const newPast = past.slice(0, past.length - 1);
41
-
27
+ if (history.past.length === 0) return history;
28
+ const previous = history.past[history.past.length - 1];
42
29
  return {
43
- past: newPast,
30
+ past: history.past.slice(0, -1),
44
31
  present: previous,
45
- future: [present, ...future],
32
+ future: [history.present, ...history.future],
46
33
  };
47
34
  }
48
35
 
49
36
  redo(history: HistoryState<T>): HistoryState<T> {
50
- const { past, present, future } = history;
51
-
52
- if (future.length === 0) {
53
- return history;
54
- }
55
-
56
- const next = future[0];
57
- const newFuture = future.slice(1);
58
-
37
+ if (history.future.length === 0) return history;
38
+ const next = history.future[0];
59
39
  return {
60
- past: [...past, present],
40
+ past: [...history.past, history.present],
61
41
  present: next,
62
- future: newFuture,
42
+ future: history.future.slice(1),
63
43
  };
64
44
  }
65
45
 
@@ -71,5 +51,3 @@ export class HistoryManager<T> {
71
51
  return history.future.length > 0;
72
52
  }
73
53
  }
74
-
75
-
@@ -0,0 +1,69 @@
1
+ import { useState, useCallback } from "react";
2
+ import * as ImagePicker from "expo-image-picker";
3
+
4
+ export interface ImagePickerResult {
5
+ uri: string;
6
+ width: number;
7
+ height: number;
8
+ }
9
+
10
+ export interface UseImagePickerReturn {
11
+ pickFromGallery: (options?: ImagePicker.ImagePickerOptions) => Promise<ImagePickerResult | null>;
12
+ takePhoto: (options?: ImagePicker.ImagePickerOptions) => Promise<ImagePickerResult | null>;
13
+ loading: boolean;
14
+ }
15
+
16
+ export const useImagePicker = (): UseImagePickerReturn => {
17
+ const [loading, setLoading] = useState(false);
18
+
19
+ const pickFromGallery = useCallback(async (
20
+ options?: ImagePicker.ImagePickerOptions,
21
+ ): Promise<ImagePickerResult | null> => {
22
+ setLoading(true);
23
+ try {
24
+ const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
25
+ if (status !== "granted") return null;
26
+
27
+ const result = await ImagePicker.launchImageLibraryAsync({
28
+ mediaTypes: ["images"],
29
+ allowsEditing: false,
30
+ quality: 1,
31
+ ...options,
32
+ });
33
+
34
+ if (result.canceled || result.assets.length === 0) return null;
35
+ const asset = result.assets[0];
36
+ return { uri: asset.uri, width: asset.width, height: asset.height };
37
+ } catch {
38
+ return null;
39
+ } finally {
40
+ setLoading(false);
41
+ }
42
+ }, []);
43
+
44
+ const takePhoto = useCallback(async (
45
+ options?: ImagePicker.ImagePickerOptions,
46
+ ): Promise<ImagePickerResult | null> => {
47
+ setLoading(true);
48
+ try {
49
+ const { status } = await ImagePicker.requestCameraPermissionsAsync();
50
+ if (status !== "granted") return null;
51
+
52
+ const result = await ImagePicker.launchCameraAsync({
53
+ mediaTypes: ["images"],
54
+ quality: 1,
55
+ ...options,
56
+ });
57
+
58
+ if (result.canceled || result.assets.length === 0) return null;
59
+ const asset = result.assets[0];
60
+ return { uri: asset.uri, width: asset.width, height: asset.height };
61
+ } catch {
62
+ return null;
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ }, []);
67
+
68
+ return { pickFromGallery, takePhoto, loading };
69
+ };
@@ -1,34 +1,31 @@
1
1
  import { useState, useCallback, useMemo } from "react";
2
- import { DesignTokens } from "@umituz/react-native-design-system";
3
- import { Layer, TextLayer, StickerLayer, ImageFilters } from "../types";
2
+ import { Layer, TextLayer, StickerLayer, ImageFilters, DEFAULT_IMAGE_FILTERS } from "../types";
4
3
  import { HistoryManager, HistoryState } from "../core/HistoryManager";
5
4
 
6
- const DEFAULT_FILTERS: ImageFilters = {
7
- brightness: 1,
8
- contrast: 1,
9
- saturation: 1,
10
- sepia: 0,
11
- grayscale: 0,
12
- };
13
-
14
5
  export const usePhotoEditor = (initialLayers: Layer[] = []) => {
15
6
  const historyManager = useMemo(() => new HistoryManager<Layer[]>(), []);
16
7
  const [history, setHistory] = useState<HistoryState<Layer[]>>(() =>
17
8
  historyManager.createInitialState(initialLayers),
18
9
  );
19
10
  const [activeLayerId, setActiveLayerId] = useState<string | null>(
20
- initialLayers[0]?.id || null,
11
+ initialLayers[0]?.id ?? null,
21
12
  );
22
- const [filters, setFilters] = useState<ImageFilters>(DEFAULT_FILTERS);
13
+ const [filters, setFilters] = useState<ImageFilters>(DEFAULT_IMAGE_FILTERS);
23
14
 
24
15
  const layers = history.present;
25
16
 
26
- const pushState = useCallback((newLayers: Layer[]) => {
27
- setHistory((prev) => historyManager.push(prev, newLayers));
28
- }, [historyManager]);
17
+ const pushState = useCallback(
18
+ (newLayers: Layer[]) => {
19
+ setHistory((prev) => historyManager.push(prev, newLayers));
20
+ },
21
+ [historyManager],
22
+ );
29
23
 
30
24
  const addTextLayer = useCallback(
31
- (tokens: DesignTokens) => {
25
+ (
26
+ defaultColor = "#FFFFFF",
27
+ overrides: Partial<Omit<TextLayer, "id" | "type">> = {},
28
+ ) => {
32
29
  const id = `text-${Date.now()}`;
33
30
  const newLayer: TextLayer = {
34
31
  id,
@@ -42,9 +39,10 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
42
39
  zIndex: layers.length,
43
40
  fontSize: 32,
44
41
  fontFamily: "System",
45
- color: tokens.colors.textPrimary,
42
+ color: defaultColor,
46
43
  backgroundColor: "transparent",
47
44
  textAlign: "center",
45
+ ...overrides,
48
46
  };
49
47
  pushState([...layers, newLayer]);
50
48
  setActiveLayerId(id);
@@ -76,7 +74,9 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
76
74
 
77
75
  const updateLayer = useCallback(
78
76
  (id: string, updates: Partial<Layer>) => {
79
- const newLayers = layers.map((l) => (l.id === id ? { ...l, ...updates } : l) as Layer);
77
+ const newLayers = layers.map(
78
+ (l) => (l.id === id ? ({ ...l, ...updates } as Layer) : l),
79
+ );
80
80
  pushState(newLayers);
81
81
  },
82
82
  [layers, pushState],
@@ -86,11 +86,60 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
86
86
  (id: string) => {
87
87
  const newLayers = layers.filter((l) => l.id !== id);
88
88
  pushState(newLayers);
89
- if (activeLayerId === id) setActiveLayerId(newLayers[0]?.id || null);
89
+ if (activeLayerId === id) {
90
+ setActiveLayerId(newLayers[0]?.id ?? null);
91
+ }
90
92
  },
91
93
  [layers, activeLayerId, pushState],
92
94
  );
93
95
 
96
+ const duplicateLayer = useCallback(
97
+ (id: string) => {
98
+ const layer = layers.find((l) => l.id === id);
99
+ if (!layer) return null;
100
+ const newId = `${layer.type}-${Date.now()}`;
101
+ const newLayer = { ...layer, id: newId, x: layer.x + 20, y: layer.y + 20, zIndex: layers.length };
102
+ pushState([...layers, newLayer]);
103
+ setActiveLayerId(newId);
104
+ return newId;
105
+ },
106
+ [layers, pushState],
107
+ );
108
+
109
+ const moveLayerUp = useCallback(
110
+ (id: string) => {
111
+ const sorted = [...layers].sort((a, b) => a.zIndex - b.zIndex);
112
+ const idx = sorted.findIndex((l) => l.id === id);
113
+ if (idx >= sorted.length - 1) return;
114
+ const reordered = [...sorted];
115
+ [reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]];
116
+ pushState(reordered.map((l, i) => ({ ...l, zIndex: i })));
117
+ },
118
+ [layers, pushState],
119
+ );
120
+
121
+ const moveLayerDown = useCallback(
122
+ (id: string) => {
123
+ const sorted = [...layers].sort((a, b) => a.zIndex - b.zIndex);
124
+ const idx = sorted.findIndex((l) => l.id === id);
125
+ if (idx <= 0) return;
126
+ const reordered = [...sorted];
127
+ [reordered[idx], reordered[idx - 1]] = [reordered[idx - 1], reordered[idx]];
128
+ pushState(reordered.map((l, i) => ({ ...l, zIndex: i })));
129
+ },
130
+ [layers, pushState],
131
+ );
132
+
133
+ const undo = useCallback(
134
+ () => setHistory((prev) => historyManager.undo(prev)),
135
+ [historyManager],
136
+ );
137
+
138
+ const redo = useCallback(
139
+ () => setHistory((prev) => historyManager.redo(prev)),
140
+ [historyManager],
141
+ );
142
+
94
143
  return {
95
144
  layers: [...layers].sort((a, b) => a.zIndex - b.zIndex),
96
145
  activeLayerId,
@@ -99,9 +148,12 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
99
148
  addStickerLayer,
100
149
  updateLayer,
101
150
  deleteLayer,
151
+ duplicateLayer,
152
+ moveLayerUp,
153
+ moveLayerDown,
102
154
  selectLayer: setActiveLayerId,
103
- undo: useCallback(() => setHistory(historyManager.undo), [historyManager]),
104
- redo: useCallback(() => setHistory(historyManager.redo), [historyManager]),
155
+ undo,
156
+ redo,
105
157
  canUndo: historyManager.canUndo(history),
106
158
  canRedo: historyManager.canRedo(history),
107
159
  filters,