@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.
@@ -0,0 +1,165 @@
1
+ import { useState, useCallback, useMemo } from "react";
2
+ import { Layer, TextLayer, ImageFilters } from "../types";
3
+ import { HistoryManager } from "../core/HistoryManager";
4
+
5
+ const DEFAULT_FILTERS: ImageFilters = {
6
+ brightness: 1,
7
+ contrast: 1,
8
+ saturation: 1,
9
+ sepia: 0,
10
+ grayscale: 0,
11
+ };
12
+
13
+ const historyManager = new HistoryManager<Layer[]>();
14
+
15
+ import { DesignTokens } from "@umituz/react-native-design-system";
16
+
17
+ export const usePhotoEditor = (initialLayers: Layer[] = []) => {
18
+ const [history, setHistory] = useState(() =>
19
+ historyManager.createInitialState(initialLayers),
20
+ );
21
+ const [activeLayerId, setActiveLayerId] = useState<string | null>(
22
+ initialLayers[0]?.id || null,
23
+ );
24
+ const [filters, setFilters] = useState<ImageFilters>(DEFAULT_FILTERS);
25
+
26
+ const layers = history.present;
27
+
28
+ const canUndo = historyManager.canUndo(history);
29
+ const canRedo = historyManager.canRedo(history);
30
+
31
+ const pushState = useCallback((newLayers: Layer[]) => {
32
+ setHistory((prev) => historyManager.push(prev, newLayers));
33
+ }, []);
34
+
35
+ const addTextLayer = useCallback(
36
+ (defaultTokens: DesignTokens) => {
37
+ const id = `text-${Date.now()}`;
38
+ const newLayer: TextLayer = {
39
+ id,
40
+ type: "text",
41
+ text: "",
42
+ x: 50,
43
+ y: 50,
44
+ rotation: 0,
45
+ scale: 1,
46
+ opacity: 1,
47
+ zIndex: layers.length,
48
+ fontSize: 32,
49
+ fontFamily: "System",
50
+ color: defaultTokens.colors.onPrimary,
51
+ backgroundColor: "transparent",
52
+ textAlign: "center",
53
+ strokeWidth: 2,
54
+ strokeColor: defaultTokens.colors.onBackground,
55
+ };
56
+
57
+ const newLayers: Layer[] = [...layers, newLayer];
58
+ pushState(newLayers);
59
+ setActiveLayerId(newLayer.id);
60
+ return id;
61
+ },
62
+ [layers, pushState],
63
+ );
64
+
65
+ const addStickerLayer = useCallback(
66
+ (uri: string) => {
67
+ const id = `sticker-${Date.now()}`;
68
+ const newLayer: Layer = {
69
+ id,
70
+ type: "sticker",
71
+ uri: uri,
72
+ x: 100,
73
+ y: 100,
74
+ rotation: 0,
75
+ scale: 1,
76
+ opacity: 1,
77
+ zIndex: layers.length,
78
+ };
79
+
80
+ const newLayers: Layer[] = [...layers, newLayer];
81
+ pushState(newLayers);
82
+ setActiveLayerId(newLayer.id);
83
+ return id;
84
+ },
85
+ [layers, pushState],
86
+ );
87
+
88
+ const updateLayer = useCallback(
89
+ (id: string, updates: Partial<Layer>, silent = false) => {
90
+ const newLayers: Layer[] = layers.map((layer) =>
91
+ layer.id === id ? ({ ...layer, ...updates } as Layer) : layer,
92
+ );
93
+ if (silent) {
94
+ // Just update state without pushing to history (hacky but works for init)
95
+ setHistory((prev) => ({ ...prev, present: newLayers }));
96
+ } else {
97
+ pushState(newLayers);
98
+ }
99
+ },
100
+ [layers, pushState],
101
+ );
102
+
103
+ const deleteLayer = useCallback(
104
+ (id: string) => {
105
+ if (layers.length <= 1) return;
106
+ const newLayers = layers.filter((layer) => layer.id !== id);
107
+ pushState(newLayers);
108
+ if (activeLayerId === id) {
109
+ setActiveLayerId(newLayers[0]?.id || null);
110
+ }
111
+ },
112
+ [layers, activeLayerId, pushState],
113
+ );
114
+
115
+ const undo = useCallback(() => {
116
+ setHistory((prev) => historyManager.undo(prev));
117
+ }, []);
118
+
119
+ const redo = useCallback(() => {
120
+ setHistory((prev) => historyManager.redo(prev));
121
+ }, []);
122
+
123
+ const selectLayer = useCallback((id: string) => {
124
+ setActiveLayerId(id);
125
+ }, []);
126
+
127
+ const bringToFront = useCallback(
128
+ (id: string) => {
129
+ const maxZ = Math.max(...layers.map((l) => l.zIndex), 0);
130
+ updateLayer(id, { zIndex: maxZ + 1 });
131
+ },
132
+ [layers, updateLayer],
133
+ );
134
+
135
+ const captureImage = useCallback(
136
+ async (_viewRef: unknown, backgroundUrl: string) => {
137
+ return backgroundUrl;
138
+ },
139
+ [],
140
+ );
141
+
142
+ const activeLayer = useMemo(
143
+ () => layers.find((l) => l.id === activeLayerId),
144
+ [layers, activeLayerId],
145
+ );
146
+
147
+ return {
148
+ layers: [...layers].sort((a, b) => a.zIndex - b.zIndex),
149
+ activeLayer,
150
+ activeLayerId,
151
+ addTextLayer,
152
+ addStickerLayer,
153
+ updateLayer,
154
+ deleteLayer,
155
+ selectLayer,
156
+ undo,
157
+ redo,
158
+ canUndo,
159
+ canRedo,
160
+ bringToFront,
161
+ filters,
162
+ updateFilters: setFilters,
163
+ captureImage,
164
+ };
165
+ };
@@ -0,0 +1,126 @@
1
+ import { useRef, useState, useCallback, useEffect } from "react";
2
+ import { BottomSheetModalRef, DesignTokens } from "@umituz/react-native-design-system";
3
+ import { usePhotoEditor } from "./usePhotoEditor";
4
+
5
+ export const usePhotoEditorUI = (
6
+ initialCaption: string | undefined,
7
+ tokens: DesignTokens,
8
+ ) => {
9
+ const textEditorSheetRef = useRef<BottomSheetModalRef>(null);
10
+ const stickerSheetRef = useRef<BottomSheetModalRef>(null);
11
+ const filterSheetRef = useRef<BottomSheetModalRef>(null);
12
+ const layerSheetRef = useRef<BottomSheetModalRef>(null);
13
+
14
+ const [selectedFont, setSelectedFont] = useState<string>("Impact");
15
+ const [fontSize, setFontSize] = useState(48);
16
+ const [editingText, setEditingText] = useState("");
17
+ const [selectedFilter, setSelectedFilter] = useState("none");
18
+
19
+ const {
20
+ layers,
21
+ activeLayerId,
22
+ addTextLayer,
23
+ addStickerLayer,
24
+ updateLayer,
25
+ deleteLayer,
26
+ selectLayer,
27
+ updateFilters,
28
+ filters,
29
+ } = usePhotoEditor([]);
30
+
31
+ // Handle initial caption
32
+ useEffect(() => {
33
+ if (initialCaption) {
34
+ const id = addTextLayer(tokens);
35
+ setTimeout(() => {
36
+ updateLayer(id, { text: initialCaption } as any, true);
37
+ }, 0);
38
+ }
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, []);
41
+
42
+ const handleAddText = useCallback(() => {
43
+ addTextLayer(tokens);
44
+ setTimeout(() => textEditorSheetRef.current?.present(), 100);
45
+ }, [addTextLayer, tokens]);
46
+
47
+ const handleTextLayerTap = useCallback(
48
+ (layerId: string) => {
49
+ selectLayer(layerId);
50
+ const layer = layers.find((l) => l.id === layerId);
51
+ if (layer?.type === "text") {
52
+ setEditingText((layer as any).text || "");
53
+ setFontSize((layer as any).fontSize || 48);
54
+ textEditorSheetRef.current?.present();
55
+ }
56
+ },
57
+ [selectLayer, layers],
58
+ );
59
+
60
+ const handleSaveText = useCallback(() => {
61
+ if (activeLayerId) {
62
+ updateLayer(activeLayerId, {
63
+ text: editingText,
64
+ fontSize,
65
+ fontFamily: selectedFont,
66
+ } as any);
67
+ }
68
+ textEditorSheetRef.current?.dismiss();
69
+ }, [activeLayerId, editingText, fontSize, selectedFont, updateLayer]);
70
+
71
+ const handleSelectFilter = useCallback(
72
+ (filterId: string, value: number) => {
73
+ setSelectedFilter(filterId);
74
+ const base = {
75
+ brightness: 1,
76
+ contrast: 1,
77
+ saturation: 1,
78
+ sepia: 0,
79
+ grayscale: 0,
80
+ };
81
+ if (filterId === "sepia") updateFilters({ ...base, sepia: value });
82
+ else if (filterId === "grayscale")
83
+ updateFilters({ ...base, grayscale: value });
84
+ else updateFilters(base);
85
+ },
86
+ [updateFilters],
87
+ );
88
+
89
+ const handleSelectSticker = useCallback(
90
+ (sticker: string) => {
91
+ addStickerLayer(sticker);
92
+ stickerSheetRef.current?.dismiss();
93
+ },
94
+ [addStickerLayer],
95
+ );
96
+
97
+ return {
98
+ // Refs
99
+ textEditorSheetRef,
100
+ stickerSheetRef,
101
+ filterSheetRef,
102
+ layerSheetRef,
103
+ // State
104
+ selectedFont,
105
+ setSelectedFont,
106
+ fontSize,
107
+ setFontSize,
108
+ editingText,
109
+ setEditingText,
110
+ selectedFilter,
111
+ // Domain State
112
+ layers,
113
+ activeLayerId,
114
+ filters,
115
+ // Domain Actions
116
+ updateLayer,
117
+ deleteLayer,
118
+ selectLayer,
119
+ // UI Actions
120
+ handleAddText,
121
+ handleTextLayerTap,
122
+ handleSaveText,
123
+ handleSelectFilter,
124
+ handleSelectSticker,
125
+ };
126
+ };
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./types";
2
+ export * from "./hooks/usePhotoEditor";
3
+ export * from "./core/HistoryManager";
4
+ export * from "./components/EditorCanvas";
5
+ export * from "./components/LayerManager";
6
+ export * from "./components/FontControls";
7
+ export * from "./components/FilterPicker";
8
+ export * from "./components/DraggableText";
9
+ export * from "./components/DraggableSticker";
10
+ export * from "./components/AIMagicSheet";
11
+ export * from "./PhotoEditor";
package/src/styles.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { StyleSheet } from "react-native";
2
+ import { DesignTokens } from "@umituz/react-native-design-system";
3
+ import { EdgeInsets } from "react-native-safe-area-context";
4
+
5
+ export const createEditorStyles = (tokens: DesignTokens, insets: EdgeInsets) =>
6
+ StyleSheet.create({
7
+ container: { flex: 1, backgroundColor: tokens.colors.background },
8
+ header: {
9
+ flexDirection: "row",
10
+ alignItems: "center",
11
+ justifyContent: "space-between",
12
+ paddingHorizontal: tokens.spacing.md,
13
+ paddingTop: insets.top + tokens.spacing.sm,
14
+ paddingBottom: tokens.spacing.sm,
15
+ },
16
+ headerButton: {
17
+ width: 48,
18
+ height: 48,
19
+ borderRadius: 24,
20
+ alignItems: "center",
21
+ justifyContent: "center",
22
+ },
23
+ headerTitle: {
24
+ fontSize: 18,
25
+ fontWeight: "bold",
26
+ color: tokens.colors.textPrimary,
27
+ },
28
+ postButton: {
29
+ backgroundColor: tokens.colors.primary,
30
+ paddingHorizontal: 20,
31
+ paddingVertical: 10,
32
+ borderRadius: 999,
33
+ },
34
+ postButtonText: {
35
+ color: tokens.colors.onPrimary,
36
+ fontWeight: "bold",
37
+ fontSize: 14,
38
+ },
39
+ scrollContent: { paddingHorizontal: tokens.spacing.md, paddingBottom: 120 },
40
+ canvas: {
41
+ width: "100%",
42
+ aspectRatio: 4 / 5,
43
+ borderRadius: 16,
44
+ overflow: "hidden",
45
+ backgroundColor: tokens.colors.surfaceVariant,
46
+ marginTop: tokens.spacing.sm,
47
+ },
48
+ canvasImage: { width: "100%", height: "100%" },
49
+ // Slider & Controls
50
+ controlsPanel: {
51
+ marginTop: tokens.spacing.md,
52
+ backgroundColor: tokens.colors.surfaceVariant + "80", // Opacity handled by token if possible, else hex
53
+ borderRadius: 16,
54
+ padding: tokens.spacing.md,
55
+ borderWidth: 1,
56
+ borderColor: tokens.colors.border,
57
+ },
58
+ sliderRow: {
59
+ flexDirection: "row",
60
+ alignItems: "center",
61
+ justifyContent: "space-between",
62
+ marginBottom: tokens.spacing.sm,
63
+ },
64
+ sliderLabel: {
65
+ flexDirection: "row",
66
+ alignItems: "center",
67
+ gap: tokens.spacing.xs,
68
+ },
69
+ sliderLabelText: { fontSize: 14, color: tokens.colors.textSecondary },
70
+ sliderValue: {
71
+ fontSize: 14,
72
+ fontWeight: "bold",
73
+ color: tokens.colors.primary,
74
+ },
75
+ sliderTrack: {
76
+ height: 6,
77
+ backgroundColor: tokens.colors.border,
78
+ borderRadius: 3,
79
+ marginBottom: tokens.spacing.lg,
80
+ },
81
+ sliderFill: {
82
+ height: "100%",
83
+ width: "65%",
84
+ backgroundColor: tokens.colors.primary,
85
+ borderRadius: 3,
86
+ },
87
+ fontLabel: {
88
+ fontSize: 14,
89
+ color: tokens.colors.textSecondary,
90
+ marginBottom: tokens.spacing.sm,
91
+ },
92
+ fontRow: { flexDirection: "row", gap: tokens.spacing.sm },
93
+ fontChip: {
94
+ paddingHorizontal: tokens.spacing.md,
95
+ paddingVertical: tokens.spacing.sm,
96
+ backgroundColor: tokens.colors.surfaceVariant,
97
+ borderRadius: 8,
98
+ borderWidth: 1,
99
+ borderColor: tokens.colors.border,
100
+ },
101
+ fontChipActive: {
102
+ backgroundColor: tokens.colors.primary,
103
+ borderColor: tokens.colors.primary,
104
+ },
105
+ fontChipText: {
106
+ fontWeight: "bold",
107
+ fontSize: 14,
108
+ color: tokens.colors.textSecondary,
109
+ },
110
+ fontChipTextActive: { color: tokens.colors.onPrimary },
111
+ // Bottom Toolbar
112
+ bottomToolbar: {
113
+ position: "absolute",
114
+ bottom: insets.bottom + tokens.spacing.md,
115
+ left: "5%",
116
+ right: "5%",
117
+ backgroundColor: tokens.colors.surfaceVariant,
118
+ borderRadius: 999,
119
+ padding: tokens.spacing.sm,
120
+ flexDirection: "row",
121
+ justifyContent: "space-around",
122
+ alignItems: "center",
123
+ borderWidth: 1,
124
+ borderColor: tokens.colors.border,
125
+ shadowColor: "#000",
126
+ shadowOffset: {
127
+ width: 0,
128
+ height: 2,
129
+ },
130
+ shadowOpacity: 0.1,
131
+ shadowRadius: 3.84,
132
+ elevation: 5,
133
+ },
134
+ toolButton: {
135
+ alignItems: "center",
136
+ justifyContent: "center",
137
+ width: 56,
138
+ height: 56,
139
+ borderRadius: 28,
140
+ },
141
+ toolButtonActive: { backgroundColor: tokens.colors.primary + "20" },
142
+ toolLabel: {
143
+ fontSize: 10,
144
+ fontWeight: "500",
145
+ color: tokens.colors.textSecondary,
146
+ marginTop: 2,
147
+ },
148
+ toolLabelActive: { color: tokens.colors.primary },
149
+ aiMagicButton: {
150
+ width: 64,
151
+ height: 64,
152
+ borderRadius: 32,
153
+ backgroundColor: tokens.colors.primary,
154
+ alignItems: "center",
155
+ justifyContent: "center",
156
+ marginTop: -32,
157
+ borderWidth: 4,
158
+ borderColor: tokens.colors.background,
159
+ },
160
+ });
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Editor Domain Types
3
+ */
4
+
5
+ export type TextAlign = "left" | "center" | "right";
6
+
7
+ export interface ImageFilters {
8
+ brightness: number;
9
+ contrast: number;
10
+ saturation: number;
11
+ sepia: number;
12
+ grayscale: number;
13
+ }
14
+
15
+ export interface BaseLayer {
16
+ id: string;
17
+ x: number;
18
+ y: number;
19
+ rotation: number;
20
+ scale: number;
21
+ opacity: number;
22
+ zIndex: number;
23
+ type: "text" | "sticker" | "image";
24
+ }
25
+
26
+ export interface TextLayer extends BaseLayer {
27
+ type: "text";
28
+ text: string;
29
+ fontSize: number;
30
+ fontFamily: string;
31
+ color: string;
32
+ backgroundColor: string;
33
+ textAlign: TextAlign;
34
+ strokeColor?: string;
35
+ strokeWidth?: number;
36
+ }
37
+
38
+ export interface StickerLayer extends BaseLayer {
39
+ type: "sticker";
40
+ uri: string;
41
+ }
42
+
43
+ export type Layer = TextLayer | StickerLayer;
44
+
45
+ export interface EditorState {
46
+ layers: Layer[];
47
+ activeLayerId: string | null;
48
+ canvasSize: { width: number; height: number };
49
+ filters: ImageFilters;
50
+ }