@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.
- package/package.json +19 -13
- package/src/PhotoEditor.tsx +162 -44
- package/src/components/AIMagicSheet.tsx +57 -33
- 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 +55 -31
- package/src/components/EditorToolbar.tsx +121 -46
- package/src/components/FilterPicker.tsx +31 -24
- package/src/components/FontControls.tsx +71 -20
- package/src/components/LayerManager.tsx +115 -30
- package/src/components/Slider.tsx +112 -0
- package/src/components/StickerPicker.tsx +5 -4
- package/src/components/TextEditorSheet.tsx +116 -10
- 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 +100 -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
|
@@ -1,23 +1,48 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { View, TextInput, StyleSheet } from "react-native";
|
|
3
|
-
import { AtomicText,
|
|
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:
|
|
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
|
-
|
|
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, 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 {
|
|
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,
|