@umituz/react-native-photo-editor 1.1.2 → 2.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.
- package/package.json +19 -13
- package/src/PhotoEditor.tsx +162 -44
- package/src/components/AIMagicSheet.tsx +54 -30
- package/src/components/AdjustmentsSheet.tsx +108 -0
- package/src/components/ColorPicker.tsx +77 -0
- package/src/components/DraggableSticker.tsx +92 -31
- package/src/components/DraggableText.tsx +98 -35
- package/src/components/EditorCanvas.tsx +38 -12
- package/src/components/EditorToolbar.tsx +130 -46
- package/src/components/FilterPicker.tsx +28 -21
- package/src/components/FontControls.tsx +68 -17
- package/src/components/LayerManager.tsx +112 -27
- package/src/components/Slider.tsx +112 -0
- package/src/components/StickerPicker.tsx +2 -1
- package/src/components/TextEditorSheet.tsx +113 -7
- package/src/constants.ts +63 -45
- package/src/core/HistoryManager.ts +11 -33
- package/src/hooks/useImagePicker.ts +69 -0
- package/src/hooks/usePhotoEditor.ts +73 -21
- package/src/hooks/usePhotoEditorUI.ts +103 -25
- package/src/index.ts +8 -0
- package/src/styles.ts +24 -6
- package/src/types.ts +12 -1
- package/src/utils/mediaUtils.ts +69 -0
- package/tsconfig.json +1 -1
package/src/constants.ts
CHANGED
|
@@ -3,60 +3,78 @@
|
|
|
3
3
|
* These can be overridden via props
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import type { ImageFilters } from "./types";
|
|
7
7
|
|
|
8
|
-
export const
|
|
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
|
|
24
|
+
export const DEFAULT_STICKERS = [
|
|
25
|
+
"😀", "😂", "🤣", "😍", "🥰", "😎", "🤯", "🥳", "😤", "💀",
|
|
26
|
+
"🔥", "❤️", "💯", "✨", "🎉", "🤡", "👀", "🙌", "👏", "💪",
|
|
27
|
+
"🤝", "🙈", "🐶", "🐱", "🦊", "🐸", "🌟", "⭐", "🌈", "☀️",
|
|
28
|
+
"🌙", "💫",
|
|
29
|
+
] as const;
|
|
44
30
|
|
|
45
31
|
export interface FilterOption {
|
|
46
|
-
id:
|
|
32
|
+
id: string;
|
|
47
33
|
name: string;
|
|
34
|
+
/** Valid AtomicIcon name */
|
|
48
35
|
icon: string;
|
|
49
|
-
|
|
36
|
+
/** Partial ImageFilters applied when this filter is selected */
|
|
37
|
+
filters: Partial<ImageFilters>;
|
|
50
38
|
}
|
|
51
39
|
|
|
52
|
-
export const DEFAULT_FILTERS = [
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 } 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 = 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 = 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 {
|
|
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
|
|
11
|
+
initialLayers[0]?.id ?? null,
|
|
21
12
|
);
|
|
22
|
-
const [filters, setFilters] = useState<ImageFilters>(
|
|
13
|
+
const [filters, setFilters] = useState<ImageFilters>(DEFAULT_IMAGE_FILTERS);
|
|
23
14
|
|
|
24
15
|
const layers = history.present;
|
|
25
16
|
|
|
26
|
-
const pushState = useCallback(
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
(
|
|
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:
|
|
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(
|
|
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)
|
|
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
|
|
104
|
-
redo
|
|
155
|
+
undo,
|
|
156
|
+
redo,
|
|
105
157
|
canUndo: historyManager.canUndo(history),
|
|
106
158
|
canRedo: historyManager.canRedo(history),
|
|
107
159
|
filters,
|
|
@@ -1,31 +1,47 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useEffect } from "react";
|
|
2
|
-
import { BottomSheetModalRef
|
|
2
|
+
import { BottomSheetModalRef } from "@umituz/react-native-design-system/molecules";
|
|
3
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
3
4
|
import { usePhotoEditor } from "./usePhotoEditor";
|
|
4
|
-
import { TextLayer } from "../types";
|
|
5
|
+
import { TextLayer, DEFAULT_IMAGE_FILTERS, ImageFilters, TextAlign } from "../types";
|
|
6
|
+
import type { FilterOption } from "../constants";
|
|
7
|
+
import type { LayerTransform } from "../components/DraggableText";
|
|
5
8
|
|
|
6
|
-
export const usePhotoEditorUI = (
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
export const usePhotoEditorUI = (initialCaption?: string) => {
|
|
10
|
+
const tokens = useAppDesignTokens();
|
|
11
|
+
|
|
12
|
+
// Bottom sheet refs
|
|
10
13
|
const textEditorSheetRef = useRef<BottomSheetModalRef>(null);
|
|
11
14
|
const stickerSheetRef = useRef<BottomSheetModalRef>(null);
|
|
12
15
|
const filterSheetRef = useRef<BottomSheetModalRef>(null);
|
|
16
|
+
const adjustmentsSheetRef = useRef<BottomSheetModalRef>(null);
|
|
13
17
|
const layerSheetRef = useRef<BottomSheetModalRef>(null);
|
|
14
18
|
const aiSheetRef = useRef<BottomSheetModalRef>(null);
|
|
15
19
|
|
|
20
|
+
// Global text/font state
|
|
16
21
|
const [selectedFont, setSelectedFont] = useState<string>("System");
|
|
17
22
|
const [fontSize, setFontSize] = useState(48);
|
|
23
|
+
|
|
24
|
+
// Per-layer text editing state (populated when sheet opens)
|
|
18
25
|
const [editingText, setEditingText] = useState("");
|
|
26
|
+
const [editingColor, setEditingColor] = useState<string>(tokens.colors.textPrimary);
|
|
27
|
+
const [editingAlign, setEditingAlign] = useState<TextAlign>("center");
|
|
28
|
+
const [editingBold, setEditingBold] = useState(false);
|
|
29
|
+
const [editingItalic, setEditingItalic] = useState(false);
|
|
30
|
+
|
|
31
|
+
// Filter state
|
|
19
32
|
const [selectedFilter, setSelectedFilter] = useState("none");
|
|
20
33
|
|
|
21
34
|
const editor = usePhotoEditor([]);
|
|
22
35
|
|
|
36
|
+
// Apply initial caption once on mount — single history entry
|
|
37
|
+
const initialCaptionApplied = useRef(false);
|
|
23
38
|
useEffect(() => {
|
|
24
|
-
if (initialCaption) {
|
|
25
|
-
|
|
26
|
-
editor.
|
|
39
|
+
if (initialCaption && !initialCaptionApplied.current) {
|
|
40
|
+
initialCaptionApplied.current = true;
|
|
41
|
+
editor.addTextLayer(tokens.colors.textPrimary, { text: initialCaption });
|
|
27
42
|
}
|
|
28
|
-
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, []);
|
|
29
45
|
|
|
30
46
|
const handleTextLayerTap = useCallback(
|
|
31
47
|
(layerId: string) => {
|
|
@@ -33,12 +49,16 @@ export const usePhotoEditorUI = (
|
|
|
33
49
|
const layer = editor.layers.find((l) => l.id === layerId);
|
|
34
50
|
if (layer?.type === "text") {
|
|
35
51
|
const textLayer = layer as TextLayer;
|
|
36
|
-
setEditingText(textLayer.text
|
|
37
|
-
setFontSize(textLayer.fontSize
|
|
52
|
+
setEditingText(textLayer.text ?? "");
|
|
53
|
+
setFontSize(textLayer.fontSize ?? 48);
|
|
54
|
+
setEditingColor(textLayer.color ?? tokens.colors.textPrimary);
|
|
55
|
+
setEditingAlign(textLayer.textAlign ?? "center");
|
|
56
|
+
setEditingBold(textLayer.isBold ?? false);
|
|
57
|
+
setEditingItalic(textLayer.isItalic ?? false);
|
|
38
58
|
textEditorSheetRef.current?.present();
|
|
39
59
|
}
|
|
40
60
|
},
|
|
41
|
-
[editor],
|
|
61
|
+
[editor, tokens.colors.textPrimary],
|
|
42
62
|
);
|
|
43
63
|
|
|
44
64
|
const handleSaveText = useCallback(() => {
|
|
@@ -47,39 +67,97 @@ export const usePhotoEditorUI = (
|
|
|
47
67
|
text: editingText,
|
|
48
68
|
fontSize,
|
|
49
69
|
fontFamily: selectedFont,
|
|
70
|
+
color: editingColor,
|
|
71
|
+
textAlign: editingAlign,
|
|
72
|
+
isBold: editingBold,
|
|
73
|
+
isItalic: editingItalic,
|
|
50
74
|
});
|
|
51
75
|
}
|
|
52
76
|
textEditorSheetRef.current?.dismiss();
|
|
53
|
-
}, [
|
|
77
|
+
}, [
|
|
78
|
+
editor,
|
|
79
|
+
editingText,
|
|
80
|
+
fontSize,
|
|
81
|
+
selectedFont,
|
|
82
|
+
editingColor,
|
|
83
|
+
editingAlign,
|
|
84
|
+
editingBold,
|
|
85
|
+
editingItalic,
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const handleSelectFilter = useCallback(
|
|
89
|
+
(option: FilterOption) => {
|
|
90
|
+
setSelectedFilter(option.id);
|
|
91
|
+
const newFilters: ImageFilters = { ...DEFAULT_IMAGE_FILTERS, ...option.filters };
|
|
92
|
+
editor.updateFilters(newFilters);
|
|
93
|
+
filterSheetRef.current?.dismiss();
|
|
94
|
+
},
|
|
95
|
+
[editor],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const handleLayerTransform = useCallback(
|
|
99
|
+
(layerId: string, transform: LayerTransform) => {
|
|
100
|
+
editor.updateLayer(layerId, {
|
|
101
|
+
x: transform.x,
|
|
102
|
+
y: transform.y,
|
|
103
|
+
scale: transform.scale,
|
|
104
|
+
rotation: transform.rotation,
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
[editor],
|
|
108
|
+
);
|
|
54
109
|
|
|
55
110
|
return {
|
|
56
111
|
...editor,
|
|
112
|
+
// Sheet refs
|
|
57
113
|
textEditorSheetRef,
|
|
58
114
|
stickerSheetRef,
|
|
59
115
|
filterSheetRef,
|
|
116
|
+
adjustmentsSheetRef,
|
|
60
117
|
layerSheetRef,
|
|
61
118
|
aiSheetRef,
|
|
119
|
+
// Font/size
|
|
62
120
|
selectedFont,
|
|
63
121
|
setSelectedFont,
|
|
64
122
|
fontSize,
|
|
65
123
|
setFontSize,
|
|
124
|
+
// Text editing
|
|
66
125
|
editingText,
|
|
67
126
|
setEditingText,
|
|
127
|
+
editingColor,
|
|
128
|
+
setEditingColor,
|
|
129
|
+
editingAlign,
|
|
130
|
+
setEditingAlign,
|
|
131
|
+
editingBold,
|
|
132
|
+
setEditingBold,
|
|
133
|
+
editingItalic,
|
|
134
|
+
setEditingItalic,
|
|
135
|
+
// Filter
|
|
68
136
|
selectedFilter,
|
|
137
|
+
// Handlers
|
|
69
138
|
handleTextLayerTap,
|
|
70
139
|
handleSaveText,
|
|
71
|
-
|
|
72
|
-
|
|
140
|
+
handleSelectFilter,
|
|
141
|
+
handleLayerTransform,
|
|
142
|
+
handleAddText: useCallback(() => {
|
|
143
|
+
const color = tokens.colors.textPrimary;
|
|
144
|
+
setEditingText("");
|
|
145
|
+
setEditingColor(color);
|
|
146
|
+
setEditingAlign("center");
|
|
147
|
+
setEditingBold(false);
|
|
148
|
+
setEditingItalic(false);
|
|
149
|
+
// Create layer with the currently active font settings so canvas preview matches sheet
|
|
150
|
+
editor.addTextLayer(color, {
|
|
151
|
+
fontSize,
|
|
152
|
+
fontFamily: selectedFont,
|
|
153
|
+
});
|
|
73
154
|
textEditorSheetRef.current?.present();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
155
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
156
|
+
}, [editor, fontSize, selectedFont, tokens.colors.textPrimary]),
|
|
157
|
+
handleSelectSticker: useCallback((uri: string) => {
|
|
158
|
+
editor.addStickerLayer(uri);
|
|
77
159
|
stickerSheetRef.current?.dismiss();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
setSelectedFilter(id);
|
|
81
|
-
editor.updateFilters({ ...editor.filters, [id]: val });
|
|
82
|
-
filterSheetRef.current?.dismiss();
|
|
83
|
-
}
|
|
160
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
161
|
+
}, [editor]),
|
|
84
162
|
};
|
|
85
163
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export * from "./types";
|
|
2
2
|
export * from "./constants";
|
|
3
3
|
export * from "./hooks/usePhotoEditor";
|
|
4
|
+
export * from "./hooks/usePhotoEditorUI";
|
|
5
|
+
export * from "./hooks/useImagePicker";
|
|
6
|
+
export * from "./utils/mediaUtils";
|
|
4
7
|
export * from "./core/HistoryManager";
|
|
5
8
|
export * from "./components/EditorCanvas";
|
|
6
9
|
export * from "./components/LayerManager";
|
|
@@ -9,4 +12,9 @@ export * from "./components/FilterPicker";
|
|
|
9
12
|
export * from "./components/DraggableText";
|
|
10
13
|
export * from "./components/DraggableSticker";
|
|
11
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";
|
|
12
20
|
export * from "./PhotoEditor";
|
package/src/styles.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StyleSheet } from "react-native";
|
|
2
|
-
import { DesignTokens } from "@umituz/react-native-design-system";
|
|
2
|
+
import { DesignTokens } from "@umituz/react-native-design-system/theme";
|
|
3
3
|
import { EdgeInsets } from "react-native-safe-area-context";
|
|
4
4
|
|
|
5
5
|
export const createEditorStyles = (tokens: DesignTokens, insets: EdgeInsets) =>
|
|
@@ -18,8 +18,8 @@ export const createEditorStyles = (tokens: DesignTokens, insets: EdgeInsets) =>
|
|
|
18
18
|
flex: 1,
|
|
19
19
|
textAlign: "center",
|
|
20
20
|
},
|
|
21
|
-
scrollContent: {
|
|
22
|
-
paddingHorizontal: tokens.spacing.md,
|
|
21
|
+
scrollContent: {
|
|
22
|
+
paddingHorizontal: tokens.spacing.md,
|
|
23
23
|
paddingBottom: 120,
|
|
24
24
|
gap: tokens.spacing.lg,
|
|
25
25
|
},
|
|
@@ -37,8 +37,18 @@ export const createEditorStyles = (tokens: DesignTokens, insets: EdgeInsets) =>
|
|
|
37
37
|
borderRadius: tokens.borders.radius.md,
|
|
38
38
|
gap: tokens.spacing.md,
|
|
39
39
|
},
|
|
40
|
-
|
|
41
|
-
flexDirection: "row",
|
|
40
|
+
sliderRow: {
|
|
41
|
+
flexDirection: "row",
|
|
42
|
+
alignItems: "center",
|
|
43
|
+
justifyContent: "space-between",
|
|
44
|
+
},
|
|
45
|
+
sliderLabel: {
|
|
46
|
+
flexDirection: "row",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
gap: tokens.spacing.xs,
|
|
49
|
+
},
|
|
50
|
+
fontRow: {
|
|
51
|
+
flexDirection: "row",
|
|
42
52
|
gap: tokens.spacing.sm,
|
|
43
53
|
flexWrap: "wrap",
|
|
44
54
|
},
|
|
@@ -74,7 +84,15 @@ export const createEditorStyles = (tokens: DesignTokens, insets: EdgeInsets) =>
|
|
|
74
84
|
padding: tokens.spacing.sm,
|
|
75
85
|
borderRadius: 999,
|
|
76
86
|
},
|
|
77
|
-
toolButtonActive: {
|
|
87
|
+
toolButtonActive: {
|
|
78
88
|
backgroundColor: tokens.colors.primary + "20",
|
|
79
89
|
},
|
|
90
|
+
aiMagicButton: {
|
|
91
|
+
width: 48,
|
|
92
|
+
height: 48,
|
|
93
|
+
borderRadius: 24,
|
|
94
|
+
backgroundColor: tokens.colors.primary,
|
|
95
|
+
alignItems: "center",
|
|
96
|
+
justifyContent: "center",
|
|
97
|
+
},
|
|
80
98
|
});
|