@umituz/react-native-photo-editor 2.0.22 → 2.0.24
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 +1 -1
- package/src/PhotoEditor.tsx +56 -44
- package/src/application/hooks/useEditor.ts +67 -0
- package/src/application/hooks/useEditorUI.ts +145 -0
- package/src/application/stores/EditorStore.ts +137 -0
- package/src/constants.ts +5 -52
- package/src/domain/entities/Filters.ts +72 -0
- package/src/domain/entities/Layer.ts +126 -0
- package/src/domain/entities/Transform.ts +55 -0
- package/src/domain/services/HistoryService.ts +60 -0
- package/src/domain/services/LayerService.ts +105 -0
- package/src/index.ts +25 -5
- package/src/infrastructure/gesture/types.ts +27 -0
- package/src/infrastructure/gesture/useTransformGesture.ts +136 -0
- package/src/infrastructure/history/HistoryManager.ts +38 -0
- package/src/presentation/components/DraggableLayer.tsx +114 -0
- package/src/presentation/components/EditorCanvas.tsx +90 -0
- package/src/presentation/components/EditorToolbar.tsx +192 -0
- package/src/presentation/components/FontControls.tsx +99 -0
- package/src/presentation/components/sheets/AIMagicSheet.tsx +99 -0
- package/src/presentation/components/sheets/AdjustmentsSheet.tsx +113 -0
- package/src/presentation/components/sheets/FilterSheet.tsx +128 -0
- package/src/presentation/components/sheets/LayerManager.tsx +151 -0
- package/src/presentation/components/sheets/StickerPicker.tsx +67 -0
- package/src/presentation/components/sheets/TextEditorSheet.tsx +159 -0
- package/src/presentation/components/ui/ColorPicker.tsx +78 -0
- package/src/presentation/components/ui/Slider.tsx +116 -0
- package/src/types.ts +13 -58
package/package.json
CHANGED
package/src/PhotoEditor.tsx
CHANGED
|
@@ -1,40 +1,36 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Photo Editor Component
|
|
3
|
+
* Main entry point for the photo editor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useCallback, useMemo } from "react";
|
|
2
7
|
import { View, ScrollView, TouchableOpacity } from "react-native";
|
|
3
|
-
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
4
8
|
import { BottomSheetModal } from "@umituz/react-native-design-system/molecules";
|
|
9
|
+
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
5
10
|
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
6
11
|
import { useSafeAreaInsets } from "@umituz/react-native-design-system/safe-area";
|
|
7
12
|
|
|
8
|
-
import EditorCanvas from "./components/EditorCanvas";
|
|
9
|
-
import { EditorToolbar } from "./components/EditorToolbar";
|
|
10
|
-
import { FontControls } from "./components/FontControls";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { AIMagicSheet } from "./components/AIMagicSheet";
|
|
17
|
-
import {
|
|
18
|
-
import { usePhotoEditorUI } from "./hooks/usePhotoEditorUI";
|
|
19
|
-
import { Layer, ImageFilters } from "./types";
|
|
13
|
+
import { EditorCanvas } from "./presentation/components/EditorCanvas";
|
|
14
|
+
import { EditorToolbar } from "./presentation/components/EditorToolbar";
|
|
15
|
+
import { FontControls } from "./presentation/components/FontControls";
|
|
16
|
+
import { TextEditorSheet } from "./presentation/components/sheets/TextEditorSheet";
|
|
17
|
+
import { StickerPicker } from "./presentation/components/sheets/StickerPicker";
|
|
18
|
+
import { FilterSheet } from "./presentation/components/sheets/FilterSheet";
|
|
19
|
+
import { AdjustmentsSheet } from "./presentation/components/sheets/AdjustmentsSheet";
|
|
20
|
+
import { LayerManager } from "./presentation/components/sheets/LayerManager";
|
|
21
|
+
import { AIMagicSheet } from "./presentation/components/sheets/AIMagicSheet";
|
|
22
|
+
import { useEditorUI } from "./application/hooks/useEditorUI";
|
|
20
23
|
import { DEFAULT_FONTS } from "./constants";
|
|
21
24
|
|
|
22
25
|
export interface PhotoEditorProps {
|
|
23
26
|
imageUri: string;
|
|
24
|
-
|
|
25
|
-
* Called when the user taps Save.
|
|
26
|
-
* Receives the original imageUri, current layers, and active filters
|
|
27
|
-
* so the host app can composite/export however it needs.
|
|
28
|
-
*/
|
|
29
|
-
onSave?: (uri: string, layers: Layer[], filters: ImageFilters) => void;
|
|
27
|
+
onSave?: (uri: string, layers: any[], filters: Record<string, number>) => void;
|
|
30
28
|
onClose: () => void;
|
|
31
29
|
title?: string;
|
|
32
|
-
|
|
33
|
-
customTools?: React.ReactNode | ((ui: ReturnType<typeof usePhotoEditorUI>) => React.ReactNode);
|
|
30
|
+
customTools?: React.ReactNode | ((ui: ReturnType<typeof useEditorUI>) => React.ReactNode);
|
|
34
31
|
initialCaption?: string;
|
|
35
32
|
t: (key: string) => string;
|
|
36
33
|
fonts?: readonly string[];
|
|
37
|
-
/** Pass a handler to enable the AI caption feature */
|
|
38
34
|
onAICaption?: (style: string) => Promise<string> | void;
|
|
39
35
|
}
|
|
40
36
|
|
|
@@ -51,14 +47,35 @@ export function PhotoEditor({
|
|
|
51
47
|
}: PhotoEditorProps) {
|
|
52
48
|
const tokens = useAppDesignTokens();
|
|
53
49
|
const insets = useSafeAreaInsets();
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
const ui = useEditorUI(initialCaption);
|
|
51
|
+
|
|
52
|
+
const styles = useMemo(() => ({
|
|
53
|
+
container: {
|
|
54
|
+
flex: 1,
|
|
55
|
+
backgroundColor: tokens.colors.surface,
|
|
56
|
+
paddingTop: insets.top,
|
|
57
|
+
},
|
|
58
|
+
header: {
|
|
59
|
+
flexDirection: "row" as const,
|
|
60
|
+
alignItems: "center" as const,
|
|
61
|
+
justifyContent: "space-between" as const,
|
|
62
|
+
paddingHorizontal: tokens.spacing.md,
|
|
63
|
+
paddingVertical: tokens.spacing.sm,
|
|
64
|
+
borderBottomWidth: 1,
|
|
65
|
+
borderBottomColor: tokens.colors.border,
|
|
66
|
+
},
|
|
67
|
+
headerTitle: {
|
|
68
|
+
flex: 1,
|
|
69
|
+
textAlign: "center",
|
|
70
|
+
},
|
|
71
|
+
scrollContent: {
|
|
72
|
+
padding: tokens.spacing.md,
|
|
73
|
+
gap: tokens.spacing.md,
|
|
74
|
+
},
|
|
75
|
+
}), [tokens, insets]);
|
|
59
76
|
|
|
60
77
|
const handleSave = useCallback(
|
|
61
|
-
() => onSave?.(imageUri, ui.layers, ui.filters),
|
|
78
|
+
() => onSave?.(imageUri, ui.layers.map(l => l.toJSON()), ui.filters),
|
|
62
79
|
[onSave, imageUri, ui.layers, ui.filters],
|
|
63
80
|
);
|
|
64
81
|
|
|
@@ -66,21 +83,13 @@ export function PhotoEditor({
|
|
|
66
83
|
<View style={styles.container}>
|
|
67
84
|
{/* Header */}
|
|
68
85
|
<View style={styles.header}>
|
|
69
|
-
<TouchableOpacity
|
|
70
|
-
onPress={onClose}
|
|
71
|
-
accessibilityLabel="Close editor"
|
|
72
|
-
accessibilityRole="button"
|
|
73
|
-
>
|
|
86
|
+
<TouchableOpacity onPress={onClose} accessibilityLabel="Close editor" accessibilityRole="button">
|
|
74
87
|
<AtomicIcon name="close" size="md" color="textPrimary" />
|
|
75
88
|
</TouchableOpacity>
|
|
76
89
|
<AtomicText type="headlineSmall" style={styles.headerTitle}>
|
|
77
90
|
{title}
|
|
78
91
|
</AtomicText>
|
|
79
|
-
<TouchableOpacity
|
|
80
|
-
onPress={handleSave}
|
|
81
|
-
accessibilityLabel="Save"
|
|
82
|
-
accessibilityRole="button"
|
|
83
|
-
>
|
|
92
|
+
<TouchableOpacity onPress={handleSave} accessibilityLabel="Save" accessibilityRole="button">
|
|
84
93
|
<AtomicText fontWeight="bold" color="primary">
|
|
85
94
|
{t("common.save") || "Save"}
|
|
86
95
|
</AtomicText>
|
|
@@ -95,7 +104,6 @@ export function PhotoEditor({
|
|
|
95
104
|
filters={ui.filters}
|
|
96
105
|
onLayerTap={ui.handleTextLayerTap}
|
|
97
106
|
onLayerTransform={ui.handleLayerTransform}
|
|
98
|
-
styles={styles}
|
|
99
107
|
/>
|
|
100
108
|
|
|
101
109
|
{typeof customTools === "function" ? customTools(ui) : customTools}
|
|
@@ -106,7 +114,6 @@ export function PhotoEditor({
|
|
|
106
114
|
fonts={fonts}
|
|
107
115
|
onFontSizeChange={ui.setFontSize}
|
|
108
116
|
onFontSelect={ui.setSelectedFont}
|
|
109
|
-
styles={styles}
|
|
110
117
|
/>
|
|
111
118
|
</ScrollView>
|
|
112
119
|
|
|
@@ -121,7 +128,6 @@ export function PhotoEditor({
|
|
|
121
128
|
onRedo={ui.redo}
|
|
122
129
|
canUndo={ui.canUndo}
|
|
123
130
|
canRedo={ui.canRedo}
|
|
124
|
-
styles={styles}
|
|
125
131
|
t={t}
|
|
126
132
|
/>
|
|
127
133
|
|
|
@@ -148,9 +154,13 @@ export function PhotoEditor({
|
|
|
148
154
|
</BottomSheetModal>
|
|
149
155
|
|
|
150
156
|
<BottomSheetModal ref={ui.filterSheetRef} snapPoints={["40%"]}>
|
|
151
|
-
<
|
|
157
|
+
<FilterSheet
|
|
152
158
|
selectedFilter={ui.selectedFilter}
|
|
153
|
-
onSelectFilter={
|
|
159
|
+
onSelectFilter={(option) => {
|
|
160
|
+
ui.setSelectedFilter(option.id);
|
|
161
|
+
ui.updateFilters(option.filters);
|
|
162
|
+
ui.filterSheetRef.current?.dismiss();
|
|
163
|
+
}}
|
|
154
164
|
/>
|
|
155
165
|
</BottomSheetModal>
|
|
156
166
|
|
|
@@ -182,3 +192,5 @@ export function PhotoEditor({
|
|
|
182
192
|
</View>
|
|
183
193
|
);
|
|
184
194
|
}
|
|
195
|
+
|
|
196
|
+
export default PhotoEditor;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use Editor Hook
|
|
3
|
+
* Main editor hook that wraps the store
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMemo } from "react";
|
|
7
|
+
import { useEditorStore } from "../stores/EditorStore";
|
|
8
|
+
import { Layer } from "../../domain/entities/Layer";
|
|
9
|
+
import type { TextLayerData } from "../../domain/entities/Layer";
|
|
10
|
+
import type { Transform } from "../../domain/entities/Transform";
|
|
11
|
+
import type { FilterValues } from "../../domain/entities/Filters";
|
|
12
|
+
|
|
13
|
+
export function useEditor() {
|
|
14
|
+
const store = useEditorStore();
|
|
15
|
+
|
|
16
|
+
return useMemo(() => ({
|
|
17
|
+
// State
|
|
18
|
+
layers: store.layers,
|
|
19
|
+
activeLayerId: store.activeLayerId,
|
|
20
|
+
activeLayer: store.activeLayer,
|
|
21
|
+
filters: store.filters,
|
|
22
|
+
canUndo: store.canUndo,
|
|
23
|
+
canRedo: store.canRedo,
|
|
24
|
+
|
|
25
|
+
// History actions
|
|
26
|
+
undo: store.undo,
|
|
27
|
+
redo: store.redo,
|
|
28
|
+
|
|
29
|
+
// Layer actions
|
|
30
|
+
addTextLayer: store.addTextLayer,
|
|
31
|
+
addStickerLayer: store.addStickerLayer,
|
|
32
|
+
updateLayer: store.updateLayer,
|
|
33
|
+
deleteLayer: store.deleteLayer,
|
|
34
|
+
duplicateLayer: store.duplicateLayer,
|
|
35
|
+
moveLayerUp: store.moveLayerUp,
|
|
36
|
+
moveLayerDown: store.moveLayerDown,
|
|
37
|
+
selectLayer: store.selectLayer,
|
|
38
|
+
|
|
39
|
+
// Filter actions
|
|
40
|
+
updateFilters: store.updateFilters,
|
|
41
|
+
resetFilters: store.resetFilters,
|
|
42
|
+
}), [
|
|
43
|
+
store.layers,
|
|
44
|
+
store.activeLayerId,
|
|
45
|
+
store.activeLayer,
|
|
46
|
+
store.filters,
|
|
47
|
+
store.canUndo,
|
|
48
|
+
store.canRedo,
|
|
49
|
+
store.undo,
|
|
50
|
+
store.redo,
|
|
51
|
+
store.addTextLayer,
|
|
52
|
+
store.addStickerLayer,
|
|
53
|
+
store.updateLayer,
|
|
54
|
+
store.deleteLayer,
|
|
55
|
+
store.duplicateLayer,
|
|
56
|
+
store.moveLayerUp,
|
|
57
|
+
store.moveLayerDown,
|
|
58
|
+
store.selectLayer,
|
|
59
|
+
store.updateFilters,
|
|
60
|
+
store.resetFilters,
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type { Layer } from "../../domain/entities/Layer";
|
|
65
|
+
export type { Transform } from "../../domain/entities/Transform";
|
|
66
|
+
export type { FilterValues } from "../../domain/entities/Filters";
|
|
67
|
+
export type { TextLayerData } from "../../domain/entities/Layer";
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use Editor UI Hook
|
|
3
|
+
* UI-specific state management (fonts, sheets, editing state)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
7
|
+
import type { BottomSheetModalRef } from "@umituz/react-native-design-system/molecules";
|
|
8
|
+
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
9
|
+
import { useEditor } from "./useEditor";
|
|
10
|
+
import type { TextAlign } from "../../domain/entities/Layer";
|
|
11
|
+
|
|
12
|
+
export function useEditorUI(initialCaption?: string) {
|
|
13
|
+
const tokens = useAppDesignTokens();
|
|
14
|
+
const editor = useEditor();
|
|
15
|
+
|
|
16
|
+
// Bottom sheet refs
|
|
17
|
+
const textEditorSheetRef = useRef<BottomSheetModalRef>(null);
|
|
18
|
+
const stickerSheetRef = useRef<BottomSheetModalRef>(null);
|
|
19
|
+
const filterSheetRef = useRef<BottomSheetModalRef>(null);
|
|
20
|
+
const adjustmentsSheetRef = useRef<BottomSheetModalRef>(null);
|
|
21
|
+
const layerSheetRef = useRef<BottomSheetModalRef>(null);
|
|
22
|
+
const aiSheetRef = useRef<BottomSheetModalRef>(null);
|
|
23
|
+
|
|
24
|
+
// Font/size state
|
|
25
|
+
const [selectedFont, setSelectedFont] = useState<string>("System");
|
|
26
|
+
const [fontSize, setFontSize] = useState(48);
|
|
27
|
+
|
|
28
|
+
// Text editing state
|
|
29
|
+
const [editingText, setEditingText] = useState("");
|
|
30
|
+
const [editingColor, setEditingColor] = useState<string>(tokens.colors.textPrimary);
|
|
31
|
+
const [editingAlign, setEditingAlign] = useState<TextAlign>("center");
|
|
32
|
+
const [editingBold, setEditingBold] = useState(false);
|
|
33
|
+
const [editingItalic, setEditingItalic] = useState(false);
|
|
34
|
+
|
|
35
|
+
// Filter state
|
|
36
|
+
const [selectedFilter, setSelectedFilter] = useState("none");
|
|
37
|
+
|
|
38
|
+
// Apply initial caption
|
|
39
|
+
const prevInitialCaptionRef = useRef<string | undefined>(undefined);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (initialCaption && initialCaption !== prevInitialCaptionRef.current) {
|
|
42
|
+
prevInitialCaptionRef.current = initialCaption;
|
|
43
|
+
editor.addTextLayer({
|
|
44
|
+
text: initialCaption,
|
|
45
|
+
color: tokens.colors.textPrimary,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}, [initialCaption, editor]);
|
|
49
|
+
|
|
50
|
+
// Handle text layer tap
|
|
51
|
+
const handleTextLayerTap = useCallback((layerId: string) => {
|
|
52
|
+
editor.selectLayer(layerId);
|
|
53
|
+
const layer = editor.layers.find(l => l.id === layerId);
|
|
54
|
+
if (layer?.type === "text") {
|
|
55
|
+
setEditingText(layer.text ?? "");
|
|
56
|
+
setFontSize(layer.fontSize ?? 48);
|
|
57
|
+
setEditingColor(layer.color ?? tokens.colors.textPrimary);
|
|
58
|
+
setEditingAlign(layer.textAlign ?? "center");
|
|
59
|
+
setEditingBold(layer.isBold ?? false);
|
|
60
|
+
setEditingItalic(layer.isItalic ?? false);
|
|
61
|
+
textEditorSheetRef.current?.present();
|
|
62
|
+
}
|
|
63
|
+
}, [editor, tokens.colors.textPrimary]);
|
|
64
|
+
|
|
65
|
+
// Handle save text
|
|
66
|
+
const handleSaveText = useCallback(() => {
|
|
67
|
+
if (editor.activeLayerId) {
|
|
68
|
+
editor.updateLayer(editor.activeLayerId, {
|
|
69
|
+
text: editingText,
|
|
70
|
+
fontSize,
|
|
71
|
+
fontFamily: selectedFont,
|
|
72
|
+
color: editingColor,
|
|
73
|
+
textAlign: editingAlign,
|
|
74
|
+
isBold: editingBold,
|
|
75
|
+
isItalic: editingItalic,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
textEditorSheetRef.current?.dismiss();
|
|
79
|
+
}, [editor, editingText, fontSize, selectedFont, editingColor, editingAlign, editingBold, editingItalic]);
|
|
80
|
+
|
|
81
|
+
// Handle add text
|
|
82
|
+
const handleAddText = useCallback(() => {
|
|
83
|
+
const color = tokens.colors.textPrimary;
|
|
84
|
+
setEditingText("");
|
|
85
|
+
setEditingColor(color);
|
|
86
|
+
setEditingAlign("center");
|
|
87
|
+
setEditingBold(false);
|
|
88
|
+
setEditingItalic(false);
|
|
89
|
+
editor.addTextLayer({ fontSize, fontFamily: selectedFont, color });
|
|
90
|
+
textEditorSheetRef.current?.present();
|
|
91
|
+
}, [editor, fontSize, selectedFont, tokens.colors.textPrimary]);
|
|
92
|
+
|
|
93
|
+
// Handle select sticker
|
|
94
|
+
const handleSelectSticker = useCallback((uri: string) => {
|
|
95
|
+
editor.addStickerLayer(uri);
|
|
96
|
+
stickerSheetRef.current?.dismiss();
|
|
97
|
+
}, [editor]);
|
|
98
|
+
|
|
99
|
+
// Handle layer transform
|
|
100
|
+
const handleLayerTransform = useCallback((layerId: string, transform: Partial<Transform>) => {
|
|
101
|
+
editor.updateLayer(layerId, transform);
|
|
102
|
+
}, [editor]);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
// Editor state
|
|
106
|
+
...editor,
|
|
107
|
+
|
|
108
|
+
// Sheet refs
|
|
109
|
+
textEditorSheetRef,
|
|
110
|
+
stickerSheetRef,
|
|
111
|
+
filterSheetRef,
|
|
112
|
+
adjustmentsSheetRef,
|
|
113
|
+
layerSheetRef,
|
|
114
|
+
aiSheetRef,
|
|
115
|
+
|
|
116
|
+
// Font/size
|
|
117
|
+
selectedFont,
|
|
118
|
+
setSelectedFont,
|
|
119
|
+
fontSize,
|
|
120
|
+
setFontSize,
|
|
121
|
+
|
|
122
|
+
// Text editing
|
|
123
|
+
editingText,
|
|
124
|
+
setEditingText,
|
|
125
|
+
editingColor,
|
|
126
|
+
setEditingColor,
|
|
127
|
+
editingAlign,
|
|
128
|
+
setEditingAlign,
|
|
129
|
+
editingBold,
|
|
130
|
+
setEditingBold,
|
|
131
|
+
editingItalic,
|
|
132
|
+
setEditingItalic,
|
|
133
|
+
|
|
134
|
+
// Filter
|
|
135
|
+
selectedFilter,
|
|
136
|
+
setSelectedFilter,
|
|
137
|
+
|
|
138
|
+
// Handlers
|
|
139
|
+
handleTextLayerTap,
|
|
140
|
+
handleSaveText,
|
|
141
|
+
handleAddText,
|
|
142
|
+
handleSelectSticker,
|
|
143
|
+
handleLayerTransform,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor Store
|
|
3
|
+
* Central state management for the editor (React hooks based)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useState, useCallback, useMemo } from "react";
|
|
7
|
+
import { Layer, TextLayer, StickerLayer } from "../../domain/entities/Layer";
|
|
8
|
+
import { FiltersVO, FilterValues, DEFAULT_FILTERS } from "../../domain/entities/Filters";
|
|
9
|
+
import { HistoryService, HistoryState } from "../../domain/services/HistoryService";
|
|
10
|
+
import { LayerService } from "../../domain/services/LayerService";
|
|
11
|
+
import type { Transform } from "../../domain/entities/Transform";
|
|
12
|
+
|
|
13
|
+
const historyService = new HistoryService<Layer[]>(20);
|
|
14
|
+
const layerService = new LayerService();
|
|
15
|
+
|
|
16
|
+
export function useEditorStore() {
|
|
17
|
+
// History state
|
|
18
|
+
const [history, setHistory] = useState<HistoryState<Layer[]>>(() =>
|
|
19
|
+
historyService.createInitialState([])
|
|
20
|
+
);
|
|
21
|
+
const [activeLayerId, setActiveLayerId] = useState<string | null>(null);
|
|
22
|
+
const [filters, setFilters] = useState<FiltersVO>(FiltersVO.default());
|
|
23
|
+
|
|
24
|
+
// History actions
|
|
25
|
+
const pushLayers = useCallback((layers: Layer[]) => {
|
|
26
|
+
setHistory((prev) => historyService.push(prev, layers));
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const undo = useCallback(() => {
|
|
30
|
+
setHistory((prev) => historyService.undo(prev));
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const redo = useCallback(() => {
|
|
34
|
+
setHistory((prev) => historyService.redo(prev));
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
// Layer actions
|
|
38
|
+
const addTextLayer = useCallback((overrides?: Partial<Omit<TextLayerData, "id" | "type">>) => {
|
|
39
|
+
const layer = layerService.createTextLayer({
|
|
40
|
+
...overrides,
|
|
41
|
+
zIndex: history.present.length,
|
|
42
|
+
});
|
|
43
|
+
pushLayers([...history.present, layer]);
|
|
44
|
+
setActiveLayerId(layer.id);
|
|
45
|
+
return layer.id;
|
|
46
|
+
}, [history.present, pushLayers]);
|
|
47
|
+
|
|
48
|
+
const addStickerLayer = useCallback((uri: string) => {
|
|
49
|
+
const layer = layerService.createStickerLayer(uri, {
|
|
50
|
+
zIndex: history.present.length,
|
|
51
|
+
});
|
|
52
|
+
pushLayers([...history.present, layer]);
|
|
53
|
+
setActiveLayerId(layer.id);
|
|
54
|
+
return layer.id;
|
|
55
|
+
}, [history.present, pushLayers]);
|
|
56
|
+
|
|
57
|
+
const updateLayer = useCallback((id: string, updates: Partial<Transform>) => {
|
|
58
|
+
const layers = layerService.updateLayer(history.present, id, updates);
|
|
59
|
+
pushLayers(layers);
|
|
60
|
+
}, [history.present, pushLayers]);
|
|
61
|
+
|
|
62
|
+
const deleteLayer = useCallback((id: string) => {
|
|
63
|
+
const layers = layerService.deleteLayer(history.present, id);
|
|
64
|
+
pushLayers(layers);
|
|
65
|
+
if (activeLayerId === id) {
|
|
66
|
+
setActiveLayerId(layers[0]?.id ?? null);
|
|
67
|
+
}
|
|
68
|
+
}, [history.present, activeLayerId, pushLayers]);
|
|
69
|
+
|
|
70
|
+
const duplicateLayer = useCallback((id: string) => {
|
|
71
|
+
const layers = layerService.duplicateLayer(history.present, id);
|
|
72
|
+
pushLayers(layers);
|
|
73
|
+
const newLayer = layers[layers.length - 1];
|
|
74
|
+
setActiveLayerId(newLayer.id);
|
|
75
|
+
}, [history.present, pushLayers]);
|
|
76
|
+
|
|
77
|
+
const moveLayerUp = useCallback((id: string) => {
|
|
78
|
+
const layers = layerService.moveLayerUp(history.present, id);
|
|
79
|
+
pushLayers(layers);
|
|
80
|
+
}, [history.present, pushLayers]);
|
|
81
|
+
|
|
82
|
+
const moveLayerDown = useCallback((id: string) => {
|
|
83
|
+
const layers = layerService.moveLayerDown(history.present, id);
|
|
84
|
+
pushLayers(layers);
|
|
85
|
+
}, [history.present, pushLayers]);
|
|
86
|
+
|
|
87
|
+
const selectLayer = useCallback((id: string | null) => {
|
|
88
|
+
setActiveLayerId(id);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Filter actions
|
|
92
|
+
const updateFilters = useCallback((updates: Partial<FilterValues>) => {
|
|
93
|
+
const current = filters.toJSON();
|
|
94
|
+
setFilters(FiltersVO.from({ ...current, ...updates }));
|
|
95
|
+
}, [filters]);
|
|
96
|
+
|
|
97
|
+
const resetFilters = useCallback(() => {
|
|
98
|
+
setFilters(FiltersVO.default());
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
// Getters
|
|
102
|
+
const layers = useMemo(() => layerService.sortByZIndex(history.present), [history.present]);
|
|
103
|
+
const activeLayer = useMemo(() =>
|
|
104
|
+
history.present.find(l => l.id === activeLayerId) ?? null,
|
|
105
|
+
[history.present, activeLayerId]
|
|
106
|
+
);
|
|
107
|
+
const canUndo = useMemo(() => historyService.canUndo(history), [history]);
|
|
108
|
+
const canRedo = useMemo(() => historyService.canRedo(history), [history]);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
// State
|
|
112
|
+
layers,
|
|
113
|
+
activeLayerId,
|
|
114
|
+
activeLayer,
|
|
115
|
+
filters: filters.toJSON(),
|
|
116
|
+
canUndo,
|
|
117
|
+
canRedo,
|
|
118
|
+
|
|
119
|
+
// History actions
|
|
120
|
+
undo,
|
|
121
|
+
redo,
|
|
122
|
+
|
|
123
|
+
// Layer actions
|
|
124
|
+
addTextLayer,
|
|
125
|
+
addStickerLayer,
|
|
126
|
+
updateLayer,
|
|
127
|
+
deleteLayer,
|
|
128
|
+
duplicateLayer,
|
|
129
|
+
moveLayerUp,
|
|
130
|
+
moveLayerDown,
|
|
131
|
+
selectLayer,
|
|
132
|
+
|
|
133
|
+
// Filter actions
|
|
134
|
+
updateFilters,
|
|
135
|
+
resetFilters,
|
|
136
|
+
};
|
|
137
|
+
}
|
package/src/constants.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Default
|
|
3
|
-
* These can be overridden via props
|
|
2
|
+
* Default Constants for Photo Editor
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
|
-
import type { ImageFilters } from "./types";
|
|
7
|
-
|
|
8
5
|
export const DEFAULT_TEXT_COLORS = [
|
|
9
6
|
"#FFFFFF", "#000000", "#888888", "#CCCCCC",
|
|
10
7
|
"#FF3B30", "#FF9500", "#FFCC00", "#FF2D55",
|
|
@@ -28,54 +25,6 @@ export const DEFAULT_STICKERS = [
|
|
|
28
25
|
"🌙", "💫",
|
|
29
26
|
] as const;
|
|
30
27
|
|
|
31
|
-
export interface FilterOption {
|
|
32
|
-
id: string;
|
|
33
|
-
name: string;
|
|
34
|
-
/** Valid AtomicIcon name */
|
|
35
|
-
icon: string;
|
|
36
|
-
/** Partial ImageFilters applied when this filter is selected */
|
|
37
|
-
filters: Partial<ImageFilters>;
|
|
38
|
-
}
|
|
39
|
-
|
|
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
|
-
];
|
|
78
|
-
|
|
79
28
|
export const DEFAULT_AI_STYLES = [
|
|
80
29
|
{ id: "viral", label: "✨ Viral", desc: "Catchy & shareable" },
|
|
81
30
|
{ id: "funny", label: "😂 Funny", desc: "Humor that connects" },
|
|
@@ -84,3 +33,7 @@ export const DEFAULT_AI_STYLES = [
|
|
|
84
33
|
{ id: "sarcastic", label: "😏 Sarcastic", desc: "Witty & ironic" },
|
|
85
34
|
{ id: "relatable", label: "🎯 Relatable", desc: "Everyone gets it" },
|
|
86
35
|
] as const;
|
|
36
|
+
|
|
37
|
+
// Filter presets will be in the presentation layer
|
|
38
|
+
export { DEFAULT_FILTERS } from "./presentation/components/sheets/FilterSheet";
|
|
39
|
+
export type { FilterOption } from "./presentation/components/sheets/FilterSheet";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filters Value Object
|
|
3
|
+
* Represents image filter adjustments
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface FilterValues {
|
|
7
|
+
brightness: number;
|
|
8
|
+
contrast: number;
|
|
9
|
+
saturation: number;
|
|
10
|
+
sepia: number;
|
|
11
|
+
grayscale: number;
|
|
12
|
+
hueRotate?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_FILTERS: FilterValues = {
|
|
16
|
+
brightness: 1,
|
|
17
|
+
contrast: 1,
|
|
18
|
+
saturation: 1,
|
|
19
|
+
sepia: 0,
|
|
20
|
+
grayscale: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class FiltersVO {
|
|
24
|
+
constructor(private readonly value: FilterValues) {}
|
|
25
|
+
|
|
26
|
+
get brightness(): number { return this.value.brightness; }
|
|
27
|
+
get contrast(): number { return this.value.contrast; }
|
|
28
|
+
get saturation(): number { return this.value.saturation; }
|
|
29
|
+
get sepia(): number { return this.value.sepia; }
|
|
30
|
+
get grayscale(): number { return this.value.grayscale; }
|
|
31
|
+
get hueRotate(): number | undefined { return this.value.hueRotate; }
|
|
32
|
+
|
|
33
|
+
withBrightness(brightness: number): FiltersVO {
|
|
34
|
+
return new FiltersVO({ ...this.value, brightness });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
withContrast(contrast: number): FiltersVO {
|
|
38
|
+
return new FiltersVO({ ...this.value, contrast });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
withSaturation(saturation: number): FiltersVO {
|
|
42
|
+
return new FiltersVO({ ...this.value, saturation });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
withSepia(sepia: number): FiltersVO {
|
|
46
|
+
return new FiltersVO({ ...this.value, sepia });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
withGrayscale(grayscale: number): FiltersVO {
|
|
50
|
+
return new FiltersVO({ ...this.value, grayscale });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
withHueRotate(hueRotate: number): FiltersVO {
|
|
54
|
+
return new FiltersVO({ ...this.value, hueRotate });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
reset(): FiltersVO {
|
|
58
|
+
return new FiltersVO(DEFAULT_FILTERS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
toJSON(): FilterValues {
|
|
62
|
+
return { ...this.value };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static from(filters: FilterValues): FiltersVO {
|
|
66
|
+
return new FiltersVO(filters);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static default(): FiltersVO {
|
|
70
|
+
return new FiltersVO(DEFAULT_FILTERS);
|
|
71
|
+
}
|
|
72
|
+
}
|