@umituz/react-native-photo-editor 2.0.10 → 2.0.11
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/eslint.config.js +4 -0
- package/package.json +15 -2
- package/src/PhotoEditor.tsx +11 -38
- package/src/hooks/useImagePicker.ts +4 -2
- package/src/hooks/usePhotoEditor.ts +25 -15
- package/src/hooks/usePhotoEditorUI.ts +12 -10
- package/src/index.ts +7 -18
- package/src/types.ts +9 -0
- package/src/utils/mediaUtils.ts +3 -2
- package/tsconfig.json +1 -0
package/eslint.config.js
CHANGED
|
@@ -13,6 +13,9 @@ module.exports = [
|
|
|
13
13
|
sourceType: "module",
|
|
14
14
|
project: "./tsconfig.json",
|
|
15
15
|
},
|
|
16
|
+
globals: {
|
|
17
|
+
console: "readonly",
|
|
18
|
+
},
|
|
16
19
|
},
|
|
17
20
|
plugins: {
|
|
18
21
|
"@typescript-eslint": tseslint,
|
|
@@ -23,6 +26,7 @@ module.exports = [
|
|
|
23
26
|
"@typescript-eslint/explicit-function-return-type": "off",
|
|
24
27
|
"@typescript-eslint/no-explicit-any": "warn",
|
|
25
28
|
"no-console": "off",
|
|
29
|
+
"no-undef": "off",
|
|
26
30
|
},
|
|
27
31
|
},
|
|
28
32
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-photo-editor",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.11",
|
|
4
4
|
"description": "A powerful, generic photo editor for React Native",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -36,12 +36,23 @@
|
|
|
36
36
|
"react-native-safe-area-context": ">=4.0.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
+
"@react-native-async-storage/async-storage": "^3.0.1",
|
|
40
|
+
"@react-navigation/bottom-tabs": "^7.15.5",
|
|
41
|
+
"@react-navigation/elements": "^2.9.10",
|
|
42
|
+
"@react-navigation/native": "^7.1.33",
|
|
43
|
+
"@react-navigation/stack": "^7.8.5",
|
|
44
|
+
"@tanstack/react-query": "^5.90.21",
|
|
39
45
|
"@types/react": "^19.1.0",
|
|
46
|
+
"@types/uuid": "^10.0.0",
|
|
40
47
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
41
48
|
"@typescript-eslint/parser": "^7.0.0",
|
|
42
49
|
"@umituz/react-native-design-system": "^4.25.90",
|
|
43
50
|
"eslint": "^8.57.0",
|
|
51
|
+
"expo-clipboard": "^55.0.8",
|
|
52
|
+
"expo-crypto": "^55.0.9",
|
|
44
53
|
"expo-file-system": "~19.0.21",
|
|
54
|
+
"expo-font": "^55.0.4",
|
|
55
|
+
"expo-haptics": "^55.0.8",
|
|
45
56
|
"expo-image": "~3.0.11",
|
|
46
57
|
"expo-image-picker": "~17.0.10",
|
|
47
58
|
"expo-media-library": "~18.2.1",
|
|
@@ -49,6 +60,8 @@
|
|
|
49
60
|
"react-native": "0.81.4",
|
|
50
61
|
"react-native-gesture-handler": "^2.30.0",
|
|
51
62
|
"react-native-safe-area-context": "^5.6.2",
|
|
52
|
-
"
|
|
63
|
+
"react-native-svg": "^15.15.3",
|
|
64
|
+
"typescript": "^5.3.0",
|
|
65
|
+
"uuid": "^13.0.0"
|
|
53
66
|
}
|
|
54
67
|
}
|
package/src/PhotoEditor.tsx
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import React, { useMemo } from "react";
|
|
1
|
+
import React, { useMemo, useCallback } from "react";
|
|
2
2
|
import { View, ScrollView, TouchableOpacity } from "react-native";
|
|
3
3
|
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
4
4
|
import { BottomSheetModal } from "@umituz/react-native-design-system/molecules";
|
|
5
5
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
6
6
|
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
7
7
|
|
|
8
|
-
import
|
|
8
|
+
import EditorCanvas from "./components/EditorCanvas";
|
|
9
9
|
import { EditorToolbar } from "./components/EditorToolbar";
|
|
10
10
|
import { FontControls } from "./components/FontControls";
|
|
11
11
|
import { LayerManager } from "./components/LayerManager";
|
|
@@ -19,19 +19,6 @@ import { usePhotoEditorUI } from "./hooks/usePhotoEditorUI";
|
|
|
19
19
|
import { Layer, ImageFilters } from "./types";
|
|
20
20
|
import { DEFAULT_FONTS } from "./constants";
|
|
21
21
|
|
|
22
|
-
export interface EditorActions {
|
|
23
|
-
addTextLayer: (defaultColor?: string) => string;
|
|
24
|
-
updateLayer: (id: string, updates: Partial<Layer>) => void;
|
|
25
|
-
duplicateLayer: (id: string) => string | null;
|
|
26
|
-
deleteLayer: (id: string) => void;
|
|
27
|
-
moveLayerUp: (id: string) => void;
|
|
28
|
-
moveLayerDown: (id: string) => void;
|
|
29
|
-
getLayers: () => Layer[];
|
|
30
|
-
getActiveLayerId: () => string | null;
|
|
31
|
-
undo: () => void;
|
|
32
|
-
redo: () => void;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
22
|
export interface PhotoEditorProps {
|
|
36
23
|
imageUri: string;
|
|
37
24
|
/**
|
|
@@ -42,8 +29,8 @@ export interface PhotoEditorProps {
|
|
|
42
29
|
onSave?: (uri: string, layers: Layer[], filters: ImageFilters) => void;
|
|
43
30
|
onClose: () => void;
|
|
44
31
|
title?: string;
|
|
45
|
-
/** Render extra tools below the canvas. Receives editor
|
|
46
|
-
customTools?: React.ReactNode | ((
|
|
32
|
+
/** Render extra tools below the canvas. Receives editor state helpers. */
|
|
33
|
+
customTools?: React.ReactNode | ((ui: ReturnType<typeof usePhotoEditorUI>) => React.ReactNode);
|
|
47
34
|
initialCaption?: string;
|
|
48
35
|
t: (key: string) => string;
|
|
49
36
|
fonts?: readonly string[];
|
|
@@ -51,7 +38,7 @@ export interface PhotoEditorProps {
|
|
|
51
38
|
onAICaption?: (style: string) => Promise<string> | void;
|
|
52
39
|
}
|
|
53
40
|
|
|
54
|
-
export
|
|
41
|
+
export function PhotoEditor({
|
|
55
42
|
imageUri,
|
|
56
43
|
onSave,
|
|
57
44
|
onClose,
|
|
@@ -61,7 +48,7 @@ export const PhotoEditor: React.FC<PhotoEditorProps> = ({
|
|
|
61
48
|
t,
|
|
62
49
|
fonts = DEFAULT_FONTS,
|
|
63
50
|
onAICaption,
|
|
64
|
-
})
|
|
51
|
+
}: PhotoEditorProps) {
|
|
65
52
|
const tokens = useAppDesignTokens();
|
|
66
53
|
const insets = useSafeAreaInsets();
|
|
67
54
|
const styles = useMemo(
|
|
@@ -70,25 +57,11 @@ export const PhotoEditor: React.FC<PhotoEditorProps> = ({
|
|
|
70
57
|
);
|
|
71
58
|
const ui = usePhotoEditorUI(initialCaption);
|
|
72
59
|
|
|
73
|
-
const
|
|
74
|
-
() => (
|
|
75
|
-
|
|
76
|
-
ui.addTextLayer(color ?? tokens.colors.textPrimary),
|
|
77
|
-
updateLayer: ui.updateLayer,
|
|
78
|
-
duplicateLayer: ui.duplicateLayer,
|
|
79
|
-
deleteLayer: ui.deleteLayer,
|
|
80
|
-
moveLayerUp: ui.moveLayerUp,
|
|
81
|
-
moveLayerDown: ui.moveLayerDown,
|
|
82
|
-
getLayers: () => ui.layers,
|
|
83
|
-
getActiveLayerId: () => ui.activeLayerId,
|
|
84
|
-
undo: ui.undo,
|
|
85
|
-
redo: ui.redo,
|
|
86
|
-
}),
|
|
87
|
-
[ui, tokens.colors.textPrimary],
|
|
60
|
+
const handleSave = useCallback(
|
|
61
|
+
() => onSave?.(imageUri, ui.layers, ui.filters),
|
|
62
|
+
[onSave, imageUri, ui.layers, ui.filters],
|
|
88
63
|
);
|
|
89
64
|
|
|
90
|
-
const handleSave = () => onSave?.(imageUri, ui.layers, ui.filters);
|
|
91
|
-
|
|
92
65
|
return (
|
|
93
66
|
<View style={styles.container}>
|
|
94
67
|
{/* Header */}
|
|
@@ -125,7 +98,7 @@ export const PhotoEditor: React.FC<PhotoEditorProps> = ({
|
|
|
125
98
|
styles={styles}
|
|
126
99
|
/>
|
|
127
100
|
|
|
128
|
-
{typeof customTools === "function" ? customTools(
|
|
101
|
+
{typeof customTools === "function" ? customTools(ui) : customTools}
|
|
129
102
|
|
|
130
103
|
<FontControls
|
|
131
104
|
fontSize={ui.fontSize}
|
|
@@ -208,4 +181,4 @@ export const PhotoEditor: React.FC<PhotoEditorProps> = ({
|
|
|
208
181
|
)}
|
|
209
182
|
</View>
|
|
210
183
|
);
|
|
211
|
-
}
|
|
184
|
+
}
|
|
@@ -34,7 +34,8 @@ export const useImagePicker = (): UseImagePickerReturn => {
|
|
|
34
34
|
if (result.canceled || result.assets.length === 0) return null;
|
|
35
35
|
const asset = result.assets[0];
|
|
36
36
|
return { uri: asset.uri, width: asset.width, height: asset.height };
|
|
37
|
-
} catch {
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error("[useImagePicker] Error picking from gallery:", error);
|
|
38
39
|
return null;
|
|
39
40
|
} finally {
|
|
40
41
|
setLoading(false);
|
|
@@ -58,7 +59,8 @@ export const useImagePicker = (): UseImagePickerReturn => {
|
|
|
58
59
|
if (result.canceled || result.assets.length === 0) return null;
|
|
59
60
|
const asset = result.assets[0];
|
|
60
61
|
return { uri: asset.uri, width: asset.width, height: asset.height };
|
|
61
|
-
} catch {
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error("[useImagePicker] Error taking photo:", error);
|
|
62
64
|
return null;
|
|
63
65
|
} finally {
|
|
64
66
|
setLoading(false);
|
|
@@ -15,7 +15,7 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
|
|
|
15
15
|
const layers = history.present;
|
|
16
16
|
|
|
17
17
|
const pushState = useCallback(
|
|
18
|
-
(newLayers: Layer[]) => {
|
|
18
|
+
(newLayers: Layer[]): void => {
|
|
19
19
|
setHistory((prev) => historyManager.push(prev, newLayers));
|
|
20
20
|
},
|
|
21
21
|
[historyManager],
|
|
@@ -73,17 +73,19 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
|
|
|
73
73
|
);
|
|
74
74
|
|
|
75
75
|
const updateLayer = useCallback(
|
|
76
|
-
(id: string, updates: Partial<Layer>) => {
|
|
77
|
-
const newLayers = layers.map(
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
(id: string, updates: Partial<Layer>): void => {
|
|
77
|
+
const newLayers = layers.map((l) => {
|
|
78
|
+
if (l.id !== id) return l;
|
|
79
|
+
// Type-safe merge: cast to Layer since we're merging valid Partial<Layer> with existing Layer
|
|
80
|
+
return { ...l, ...updates } as Layer;
|
|
81
|
+
});
|
|
80
82
|
pushState(newLayers);
|
|
81
83
|
},
|
|
82
84
|
[layers, pushState],
|
|
83
85
|
);
|
|
84
86
|
|
|
85
87
|
const deleteLayer = useCallback(
|
|
86
|
-
(id: string) => {
|
|
88
|
+
(id: string): void => {
|
|
87
89
|
const newLayers = layers.filter((l) => l.id !== id);
|
|
88
90
|
pushState(newLayers);
|
|
89
91
|
if (activeLayerId === id) {
|
|
@@ -94,11 +96,13 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
|
|
|
94
96
|
);
|
|
95
97
|
|
|
96
98
|
const duplicateLayer = useCallback(
|
|
97
|
-
(id: string) => {
|
|
99
|
+
(id: string): string | null => {
|
|
98
100
|
const layer = layers.find((l) => l.id === id);
|
|
99
101
|
if (!layer) return null;
|
|
100
102
|
const newId = `${layer.type}-${Date.now()}`;
|
|
101
|
-
|
|
103
|
+
// Calculate the next zIndex to avoid conflicts
|
|
104
|
+
const maxZIndex = layers.length > 0 ? Math.max(...layers.map((l) => l.zIndex)) : -1;
|
|
105
|
+
const newLayer = { ...layer, id: newId, x: layer.x + 20, y: layer.y + 20, zIndex: maxZIndex + 1 };
|
|
102
106
|
pushState([...layers, newLayer]);
|
|
103
107
|
setActiveLayerId(newId);
|
|
104
108
|
return newId;
|
|
@@ -107,7 +111,7 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
|
|
|
107
111
|
);
|
|
108
112
|
|
|
109
113
|
const moveLayerUp = useCallback(
|
|
110
|
-
(id: string) => {
|
|
114
|
+
(id: string): void => {
|
|
111
115
|
const sorted = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
|
112
116
|
const idx = sorted.findIndex((l) => l.id === id);
|
|
113
117
|
if (idx >= sorted.length - 1) return;
|
|
@@ -131,19 +135,25 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
|
|
|
131
135
|
);
|
|
132
136
|
|
|
133
137
|
const undo = useCallback(
|
|
134
|
-
() => setHistory((prev) => historyManager.undo(prev)),
|
|
138
|
+
(): void => setHistory((prev) => historyManager.undo(prev)),
|
|
135
139
|
[historyManager],
|
|
136
140
|
);
|
|
137
141
|
|
|
138
142
|
const redo = useCallback(
|
|
139
|
-
() => setHistory((prev) => historyManager.redo(prev)),
|
|
143
|
+
(): void => setHistory((prev) => historyManager.redo(prev)),
|
|
140
144
|
[historyManager],
|
|
141
145
|
);
|
|
142
146
|
|
|
147
|
+
// Memoize return object to prevent infinite re-renders
|
|
148
|
+
const sortedLayers = useMemo(() => [...layers].sort((a, b) => a.zIndex - b.zIndex), [layers]);
|
|
149
|
+
const activeLayer = useMemo(() => layers.find((l) => l.id === activeLayerId), [layers, activeLayerId]);
|
|
150
|
+
const canUndo = useMemo(() => historyManager.canUndo(history), [history]);
|
|
151
|
+
const canRedo = useMemo(() => historyManager.canRedo(history), [history]);
|
|
152
|
+
|
|
143
153
|
return {
|
|
144
|
-
layers:
|
|
154
|
+
layers: sortedLayers,
|
|
145
155
|
activeLayerId,
|
|
146
|
-
activeLayer
|
|
156
|
+
activeLayer,
|
|
147
157
|
addTextLayer,
|
|
148
158
|
addStickerLayer,
|
|
149
159
|
updateLayer,
|
|
@@ -154,8 +164,8 @@ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
|
|
|
154
164
|
selectLayer: setActiveLayerId,
|
|
155
165
|
undo,
|
|
156
166
|
redo,
|
|
157
|
-
canUndo
|
|
158
|
-
canRedo
|
|
167
|
+
canUndo,
|
|
168
|
+
canRedo,
|
|
159
169
|
filters,
|
|
160
170
|
updateFilters: setFilters,
|
|
161
171
|
};
|
|
@@ -34,16 +34,18 @@ export const usePhotoEditorUI = (initialCaption?: string) => {
|
|
|
34
34
|
const editor = usePhotoEditor([]);
|
|
35
35
|
|
|
36
36
|
// Apply initial caption once on mount — single history entry
|
|
37
|
-
const
|
|
37
|
+
const prevInitialCaptionRef = useRef<string | undefined>(undefined);
|
|
38
|
+
|
|
38
39
|
useEffect(() => {
|
|
39
|
-
if
|
|
40
|
-
|
|
40
|
+
// Only apply if initialCaption changed and is different from previous value
|
|
41
|
+
if (initialCaption && initialCaption !== prevInitialCaptionRef.current) {
|
|
42
|
+
prevInitialCaptionRef.current = initialCaption;
|
|
41
43
|
editor.addTextLayer(tokens.colors.textPrimary, { text: initialCaption });
|
|
42
44
|
}
|
|
43
|
-
}, []);
|
|
45
|
+
}, [initialCaption, editor]);
|
|
44
46
|
|
|
45
47
|
const handleTextLayerTap = useCallback(
|
|
46
|
-
(layerId: string) => {
|
|
48
|
+
(layerId: string): void => {
|
|
47
49
|
editor.selectLayer(layerId);
|
|
48
50
|
const layer = editor.layers.find((l) => l.id === layerId);
|
|
49
51
|
if (layer?.type === "text") {
|
|
@@ -60,7 +62,7 @@ export const usePhotoEditorUI = (initialCaption?: string) => {
|
|
|
60
62
|
[editor, tokens.colors.textPrimary],
|
|
61
63
|
);
|
|
62
64
|
|
|
63
|
-
const handleSaveText = useCallback(() => {
|
|
65
|
+
const handleSaveText = useCallback((): void => {
|
|
64
66
|
if (editor.activeLayerId) {
|
|
65
67
|
editor.updateLayer(editor.activeLayerId, {
|
|
66
68
|
text: editingText,
|
|
@@ -85,7 +87,7 @@ export const usePhotoEditorUI = (initialCaption?: string) => {
|
|
|
85
87
|
]);
|
|
86
88
|
|
|
87
89
|
const handleSelectFilter = useCallback(
|
|
88
|
-
(option: FilterOption) => {
|
|
90
|
+
(option: FilterOption): void => {
|
|
89
91
|
setSelectedFilter(option.id);
|
|
90
92
|
const newFilters: ImageFilters = { ...DEFAULT_IMAGE_FILTERS, ...option.filters };
|
|
91
93
|
editor.updateFilters(newFilters);
|
|
@@ -95,7 +97,7 @@ export const usePhotoEditorUI = (initialCaption?: string) => {
|
|
|
95
97
|
);
|
|
96
98
|
|
|
97
99
|
const handleLayerTransform = useCallback(
|
|
98
|
-
(layerId: string, transform: LayerTransform) => {
|
|
100
|
+
(layerId: string, transform: LayerTransform): void => {
|
|
99
101
|
editor.updateLayer(layerId, {
|
|
100
102
|
x: transform.x,
|
|
101
103
|
y: transform.y,
|
|
@@ -138,7 +140,7 @@ export const usePhotoEditorUI = (initialCaption?: string) => {
|
|
|
138
140
|
handleSaveText,
|
|
139
141
|
handleSelectFilter,
|
|
140
142
|
handleLayerTransform,
|
|
141
|
-
handleAddText: useCallback(() => {
|
|
143
|
+
handleAddText: useCallback((): void => {
|
|
142
144
|
const color = tokens.colors.textPrimary;
|
|
143
145
|
setEditingText("");
|
|
144
146
|
setEditingColor(color);
|
|
@@ -152,7 +154,7 @@ export const usePhotoEditorUI = (initialCaption?: string) => {
|
|
|
152
154
|
});
|
|
153
155
|
textEditorSheetRef.current?.present();
|
|
154
156
|
}, [editor, fontSize, selectedFont, tokens.colors.textPrimary]),
|
|
155
|
-
handleSelectSticker: useCallback((uri: string) => {
|
|
157
|
+
handleSelectSticker: useCallback((uri: string): void => {
|
|
156
158
|
editor.addStickerLayer(uri);
|
|
157
159
|
stickerSheetRef.current?.dismiss();
|
|
158
160
|
}, [editor]),
|
package/src/index.ts
CHANGED
|
@@ -1,20 +1,9 @@
|
|
|
1
|
+
// Public API exports
|
|
2
|
+
export { PhotoEditor } from "./PhotoEditor";
|
|
3
|
+
export type { PhotoEditorProps } from "./PhotoEditor";
|
|
4
|
+
|
|
5
|
+
// Type exports for consumer usage
|
|
1
6
|
export * from "./types";
|
|
7
|
+
|
|
8
|
+
// Constant exports for consumer customization
|
|
2
9
|
export * from "./constants";
|
|
3
|
-
export * from "./hooks/usePhotoEditor";
|
|
4
|
-
export * from "./hooks/usePhotoEditorUI";
|
|
5
|
-
export * from "./hooks/useImagePicker";
|
|
6
|
-
export * from "./utils/mediaUtils";
|
|
7
|
-
export * from "./core/HistoryManager";
|
|
8
|
-
export * from "./components/EditorCanvas";
|
|
9
|
-
export * from "./components/LayerManager";
|
|
10
|
-
export * from "./components/FontControls";
|
|
11
|
-
export * from "./components/FilterPicker";
|
|
12
|
-
export * from "./components/DraggableText";
|
|
13
|
-
export * from "./components/DraggableSticker";
|
|
14
|
-
export * from "./components/AIMagicSheet";
|
|
15
|
-
export * from "./components/TextEditorSheet";
|
|
16
|
-
export * from "./components/StickerPicker";
|
|
17
|
-
export * from "./components/EditorToolbar";
|
|
18
|
-
export * from "./components/ColorPicker";
|
|
19
|
-
export * from "./components/AdjustmentsSheet";
|
|
20
|
-
export * from "./PhotoEditor";
|
package/src/types.ts
CHANGED
|
@@ -59,3 +59,12 @@ export interface EditorState {
|
|
|
59
59
|
canvasSize: { width: number; height: number };
|
|
60
60
|
filters: ImageFilters;
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
// Type guards for discriminating union types
|
|
64
|
+
export function isTextLayer(layer: Layer): layer is TextLayer {
|
|
65
|
+
return layer.type === "text";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isStickerLayer(layer: Layer): layer is StickerLayer {
|
|
69
|
+
return layer.type === "sticker";
|
|
70
|
+
}
|
package/src/utils/mediaUtils.ts
CHANGED
|
@@ -63,7 +63,8 @@ export const deleteLocalFile = async (uri: string): Promise<void> => {
|
|
|
63
63
|
if (file.exists) {
|
|
64
64
|
file.delete();
|
|
65
65
|
}
|
|
66
|
-
} catch {
|
|
67
|
-
//
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Best-effort cleanup — log but don't throw
|
|
68
|
+
console.warn("[mediaUtils] Failed to delete local file:", uri, error);
|
|
68
69
|
}
|
|
69
70
|
};
|