@umituz/react-native-photo-editor 2.0.24 → 2.0.26
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 +43 -137
- package/src/application/hooks/useEditor.ts +4 -6
- package/src/application/hooks/useEditorUI.ts +8 -5
- package/src/application/stores/EditorStore.ts +42 -17
- package/src/domain/entities/Layer.entity.ts +138 -0
- package/src/domain/entities/{Layer.ts → Layer.legacy.ts} +3 -3
- package/src/domain/entities/StickerLayer.entity.ts +37 -0
- package/src/domain/entities/TextLayer.entity.ts +58 -0
- package/src/domain/entities/index.ts +9 -0
- package/src/domain/services/History.service.ts +69 -0
- package/src/domain/services/LayerFactory.service.ts +81 -0
- package/src/domain/services/LayerRepository.service.ts +90 -0
- package/src/domain/services/LayerService.ts +21 -23
- package/src/domain/types.ts +39 -0
- package/src/domain/value-objects/FilterSettings.vo.ts +89 -0
- package/src/domain/value-objects/LayerDefaults.vo.ts +56 -0
- package/src/domain/value-objects/Transform.vo.ts +61 -0
- package/src/domain/value-objects/index.ts +13 -0
- package/src/index.ts +6 -4
- package/src/infrastructure/gesture/createTransformGesture.ts +127 -0
- package/src/infrastructure/gesture/useTransformGesture.ts +7 -13
- package/src/presentation/components/DraggableLayer.tsx +13 -13
- package/src/presentation/components/EditorCanvas.tsx +5 -5
- package/src/presentation/components/EditorContent.tsx +72 -0
- package/src/presentation/components/EditorHeader.tsx +48 -0
- package/src/presentation/components/EditorSheets.tsx +85 -0
- package/src/presentation/components/FontControls.tsx +2 -2
- package/src/presentation/components/sheets/AdjustmentsSheet.tsx +4 -4
- package/src/presentation/components/sheets/FilterSheet.tsx +1 -1
- package/src/presentation/components/sheets/LayerManager.tsx +3 -4
- package/src/presentation/components/sheets/TextEditorSheet.tsx +1 -1
- package/src/types.ts +9 -18
- package/src/utils/constants.ts +84 -0
- package/src/utils/formatters.ts +29 -0
- package/src/utils/helpers.ts +51 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/validators.ts +38 -0
- package/src/components/AIMagicSheet.tsx +0 -107
- package/src/components/AdjustmentsSheet.tsx +0 -108
- package/src/components/ColorPicker.tsx +0 -77
- package/src/components/DraggableSticker.tsx +0 -161
- package/src/components/DraggableText.tsx +0 -181
- package/src/components/EditorCanvas.tsx +0 -106
- package/src/components/EditorToolbar.tsx +0 -155
- package/src/components/FilterPicker.tsx +0 -73
- package/src/components/FontControls.tsx +0 -132
- package/src/components/LayerManager.tsx +0 -164
- package/src/components/Slider.tsx +0 -112
- package/src/components/StickerPicker.tsx +0 -47
- package/src/components/TextEditorSheet.tsx +0 -160
- package/src/core/HistoryManager.ts +0 -53
- package/src/hooks/usePhotoEditor.ts +0 -172
- package/src/hooks/usePhotoEditorUI.ts +0 -162
- package/src/infrastructure/history/HistoryManager.ts +0 -38
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants Utility
|
|
3
|
+
* Shared constants for the editor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_COLORS = [
|
|
7
|
+
"#FFFFFF", "#000000", "#888888", "#CCCCCC",
|
|
8
|
+
"#FF3B30", "#FF9500", "#FFCC00", "#FF2D55",
|
|
9
|
+
"#34C759", "#30B0C7", "#007AFF", "#5AC8FA",
|
|
10
|
+
"#5856D6", "#AF52DE", "#FF6B6B", "#FFD93D",
|
|
11
|
+
"#6BCB77", "#4D96FF", "#C77DFF", "#F72585",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_FONTS = [
|
|
15
|
+
"System",
|
|
16
|
+
"Impact",
|
|
17
|
+
"Comic",
|
|
18
|
+
"Serif",
|
|
19
|
+
"Retro",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_STICKERS = [
|
|
23
|
+
"😀", "😂", "🤣", "😍", "🥰", "😎", "🤯", "🥳", "😤", "💀",
|
|
24
|
+
"🔥", "❤️", "💯", "✨", "🎉", "🤡", "👀", "🙌", "👏", "💪",
|
|
25
|
+
"🤝", "🙈", "🐶", "🐱", "🦊", "🐸", "🌟", "⭐", "🌈", "☀️",
|
|
26
|
+
"🌙", "💫",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
export const AI_STYLES = [
|
|
30
|
+
{ id: "viral", label: "✨ Viral", desc: "Catchy & shareable" },
|
|
31
|
+
{ id: "funny", label: "😂 Funny", desc: "Humor that connects" },
|
|
32
|
+
{ id: "savage", label: "🔥 Savage", desc: "Bold & edgy" },
|
|
33
|
+
{ id: "wholesome", label: "💕 Wholesome", desc: "Warm & positive" },
|
|
34
|
+
{ id: "sarcastic", label: "😏 Sarcastic", desc: "Witty & ironic" },
|
|
35
|
+
{ id: "relatable", label: "🎯 Relatable", desc: "Everyone gets it" },
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export const FILTER_PRESETS = [
|
|
39
|
+
{
|
|
40
|
+
id: "none",
|
|
41
|
+
name: "None",
|
|
42
|
+
icon: "close",
|
|
43
|
+
filters: { brightness: 1, contrast: 1, saturation: 1, sepia: 0, grayscale: 0 },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "sepia",
|
|
47
|
+
name: "Sepia",
|
|
48
|
+
icon: "brush",
|
|
49
|
+
filters: { sepia: 0.7, saturation: 0.8 },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "grayscale",
|
|
53
|
+
name: "B&W",
|
|
54
|
+
icon: "swap-horizontal",
|
|
55
|
+
filters: { grayscale: 1, saturation: 0 },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "vintage",
|
|
59
|
+
name: "Vintage",
|
|
60
|
+
icon: "flash",
|
|
61
|
+
filters: { sepia: 0.3, contrast: 1.1, brightness: 0.9 },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "warm",
|
|
65
|
+
name: "Warm",
|
|
66
|
+
icon: "sparkles",
|
|
67
|
+
filters: { brightness: 1.05, saturation: 1.2 },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "cool",
|
|
71
|
+
name: "Cool",
|
|
72
|
+
icon: "image",
|
|
73
|
+
filters: { contrast: 1.05, brightness: 1.02, saturation: 0.85 },
|
|
74
|
+
},
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
export const SLIDER_CONFIGS = {
|
|
78
|
+
brightness: { min: 0.5, max: 2, step: 0.05, default: 1 },
|
|
79
|
+
contrast: { min: 0.5, max: 2, step: 0.05, default: 1 },
|
|
80
|
+
saturation: { min: 0, max: 2, step: 0.05, default: 1 },
|
|
81
|
+
hueRotate: { min: 0, max: 360, step: 1, default: 0 },
|
|
82
|
+
sepia: { min: 0, max: 1, step: 0.05, default: 0 },
|
|
83
|
+
grayscale: { min: 0, max: 1, step: 0.05, default: 0 },
|
|
84
|
+
} as const;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatters Utility
|
|
3
|
+
* Value formatting for UI display
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function formatPercentage(value: number): string {
|
|
7
|
+
const rounded = (value - 1) * 100;
|
|
8
|
+
return `${rounded >= 0 ? "+" : ""}${Math.round(rounded)}%`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatDegrees(value: number): string {
|
|
12
|
+
return `${Math.round(value)}°`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatSliderValue(
|
|
16
|
+
value: number,
|
|
17
|
+
type: "percentage" | "degrees" | "integer" = "percentage"
|
|
18
|
+
): string {
|
|
19
|
+
switch (type) {
|
|
20
|
+
case "percentage":
|
|
21
|
+
return formatPercentage(value);
|
|
22
|
+
case "degrees":
|
|
23
|
+
return formatDegrees(value);
|
|
24
|
+
case "integer":
|
|
25
|
+
return Math.round(value).toString();
|
|
26
|
+
default:
|
|
27
|
+
return value.toString();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers Utility
|
|
3
|
+
* Common helper functions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FilterData } from "../domain/value-objects/FilterSettings.vo";
|
|
7
|
+
|
|
8
|
+
export function createBrightnessOverlay(brightness: number): {
|
|
9
|
+
color: string;
|
|
10
|
+
opacity: number;
|
|
11
|
+
} | null {
|
|
12
|
+
if (brightness < 1) {
|
|
13
|
+
return { color: "black", opacity: Math.min(0.6, 1 - brightness) };
|
|
14
|
+
}
|
|
15
|
+
if (brightness > 1) {
|
|
16
|
+
return { color: "white", opacity: Math.min(0.4, brightness - 1) };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function mergeFilters(
|
|
22
|
+
base: FilterData,
|
|
23
|
+
updates: Partial<FilterData>
|
|
24
|
+
): FilterData {
|
|
25
|
+
return {
|
|
26
|
+
...base,
|
|
27
|
+
...updates,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateId(prefix: string): string {
|
|
32
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sortByZIndex<T extends { zIndex: number }>(
|
|
36
|
+
items: T[]
|
|
37
|
+
): T[] {
|
|
38
|
+
return [...items].sort((a, b) => a.zIndex - b.zIndex);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getNextZIndex(items: { zIndex: number }[]): number {
|
|
42
|
+
return items.length > 0 ? Math.max(...items.map((item) => item.zIndex)) + 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function capitalizeFirst(str: string): string {
|
|
46
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function truncateText(text: string, maxLength: number): string {
|
|
50
|
+
return text.length > maxLength ? text.slice(0, maxLength) + "..." : text;
|
|
51
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validators Utility
|
|
3
|
+
* Input validation for editor operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function isValidColor(color: string): boolean {
|
|
7
|
+
return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isValidUrl(url: string): boolean {
|
|
11
|
+
try {
|
|
12
|
+
new URL(url);
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isEmojiString(str: string): boolean {
|
|
20
|
+
return (
|
|
21
|
+
str.length <= 4 &&
|
|
22
|
+
!/^https?:\/\//i.test(str) &&
|
|
23
|
+
!str.startsWith("/") &&
|
|
24
|
+
/^[\p{Emoji}\p{Emoji_Component}]+$/u.test(str)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
29
|
+
return Math.min(Math.max(value, min), max);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateFontSize(size: number): number {
|
|
33
|
+
return clamp(size, 8, 120);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateOpacity(opacity: number): number {
|
|
37
|
+
return clamp(opacity, 0, 1);
|
|
38
|
+
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import React, { useState, useMemo } from "react";
|
|
2
|
-
import { View, ScrollView, TouchableOpacity, StyleSheet } from "react-native";
|
|
3
|
-
import { AtomicText, AtomicIcon, AtomicButton } from "@umituz/react-native-design-system/atoms";
|
|
4
|
-
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
5
|
-
import { DEFAULT_AI_STYLES } from "../constants";
|
|
6
|
-
|
|
7
|
-
interface AIMagicSheetProps {
|
|
8
|
-
/**
|
|
9
|
-
* Called with the selected style ID. Should return a generated caption string.
|
|
10
|
-
* If undefined, the AI button is disabled.
|
|
11
|
-
*/
|
|
12
|
-
onGenerateCaption?: (style: string) => Promise<string> | void;
|
|
13
|
-
isLoading?: boolean;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const AIMagicSheet: React.FC<AIMagicSheetProps> = ({
|
|
17
|
-
onGenerateCaption,
|
|
18
|
-
isLoading = false,
|
|
19
|
-
}) => {
|
|
20
|
-
const tokens = useAppDesignTokens();
|
|
21
|
-
const [selected, setSelected] = useState<string | null>(null);
|
|
22
|
-
const [loading, setLoading] = useState(false);
|
|
23
|
-
|
|
24
|
-
const styles = useMemo(() => StyleSheet.create({
|
|
25
|
-
container: { padding: tokens.spacing.md, gap: tokens.spacing.md },
|
|
26
|
-
header: { flexDirection: "row", alignItems: "center", gap: tokens.spacing.sm },
|
|
27
|
-
grid: { gap: tokens.spacing.sm },
|
|
28
|
-
card: {
|
|
29
|
-
flexDirection: "row",
|
|
30
|
-
alignItems: "center",
|
|
31
|
-
padding: tokens.spacing.md,
|
|
32
|
-
backgroundColor: tokens.colors.surfaceVariant,
|
|
33
|
-
borderRadius: tokens.borders.radius.md,
|
|
34
|
-
borderWidth: 2,
|
|
35
|
-
borderColor: "transparent",
|
|
36
|
-
},
|
|
37
|
-
cardActive: {
|
|
38
|
-
borderColor: tokens.colors.primary,
|
|
39
|
-
backgroundColor: tokens.colors.primary + "10",
|
|
40
|
-
},
|
|
41
|
-
info: { flex: 1, marginLeft: tokens.spacing.sm },
|
|
42
|
-
}), [tokens]);
|
|
43
|
-
|
|
44
|
-
const handleGenerate = async () => {
|
|
45
|
-
if (!selected || !onGenerateCaption) return;
|
|
46
|
-
setLoading(true);
|
|
47
|
-
try {
|
|
48
|
-
await onGenerateCaption(selected);
|
|
49
|
-
} finally {
|
|
50
|
-
setLoading(false);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const isGenerating = isLoading || loading;
|
|
55
|
-
|
|
56
|
-
return (
|
|
57
|
-
<View style={styles.container}>
|
|
58
|
-
<View style={styles.header}>
|
|
59
|
-
<AtomicIcon name="sparkles" size="md" color="primary" />
|
|
60
|
-
<AtomicText type="headlineSmall">AI Caption Magic</AtomicText>
|
|
61
|
-
</View>
|
|
62
|
-
<ScrollView showsVerticalScrollIndicator={false}>
|
|
63
|
-
<View style={styles.grid}>
|
|
64
|
-
{DEFAULT_AI_STYLES.map((style) => {
|
|
65
|
-
const isActive = selected === style.id;
|
|
66
|
-
const [emoji, ...words] = style.label.split(" ");
|
|
67
|
-
return (
|
|
68
|
-
<TouchableOpacity
|
|
69
|
-
key={style.id}
|
|
70
|
-
style={[styles.card, isActive && styles.cardActive]}
|
|
71
|
-
onPress={() => setSelected(style.id)}
|
|
72
|
-
accessibilityLabel={style.label}
|
|
73
|
-
accessibilityRole="button"
|
|
74
|
-
accessibilityState={{ selected: isActive }}
|
|
75
|
-
>
|
|
76
|
-
<AtomicText style={{ fontSize: 24 }}>{emoji}</AtomicText>
|
|
77
|
-
<View style={styles.info}>
|
|
78
|
-
<AtomicText
|
|
79
|
-
fontWeight="bold"
|
|
80
|
-
color={isActive ? "primary" : "textPrimary"}
|
|
81
|
-
>
|
|
82
|
-
{words.join(" ")}
|
|
83
|
-
</AtomicText>
|
|
84
|
-
<AtomicText type="labelSmall" color="textSecondary">
|
|
85
|
-
{style.desc}
|
|
86
|
-
</AtomicText>
|
|
87
|
-
</View>
|
|
88
|
-
{isActive && (
|
|
89
|
-
<AtomicIcon name="checkmark-circle" size="md" color="primary" />
|
|
90
|
-
)}
|
|
91
|
-
</TouchableOpacity>
|
|
92
|
-
);
|
|
93
|
-
})}
|
|
94
|
-
</View>
|
|
95
|
-
</ScrollView>
|
|
96
|
-
<AtomicButton
|
|
97
|
-
variant="primary"
|
|
98
|
-
disabled={!selected || !onGenerateCaption || isGenerating}
|
|
99
|
-
onPress={handleGenerate}
|
|
100
|
-
loading={isGenerating}
|
|
101
|
-
icon="sparkles"
|
|
102
|
-
>
|
|
103
|
-
Generate Caption
|
|
104
|
-
</AtomicButton>
|
|
105
|
-
</View>
|
|
106
|
-
);
|
|
107
|
-
};
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { View, TouchableOpacity } from "react-native";
|
|
3
|
-
import { AtomicText, AtomicIcon } from "@umituz/react-native-design-system/atoms";
|
|
4
|
-
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
5
|
-
import { Slider } from "./Slider";
|
|
6
|
-
import { ImageFilters, DEFAULT_IMAGE_FILTERS } from "../types";
|
|
7
|
-
|
|
8
|
-
interface AdjustmentsSheetProps {
|
|
9
|
-
filters: ImageFilters;
|
|
10
|
-
onFiltersChange: (filters: ImageFilters) => void;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const AdjustmentsSheet: React.FC<AdjustmentsSheetProps> = ({
|
|
14
|
-
filters,
|
|
15
|
-
onFiltersChange,
|
|
16
|
-
}) => {
|
|
17
|
-
const tokens = useAppDesignTokens();
|
|
18
|
-
|
|
19
|
-
const update = (key: keyof ImageFilters, val: number) => {
|
|
20
|
-
onFiltersChange({ ...filters, [key]: val });
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const handleReset = () => onFiltersChange(DEFAULT_IMAGE_FILTERS);
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<View style={{ padding: tokens.spacing.md, gap: tokens.spacing.lg }}>
|
|
27
|
-
<View
|
|
28
|
-
style={{
|
|
29
|
-
flexDirection: "row",
|
|
30
|
-
alignItems: "center",
|
|
31
|
-
justifyContent: "space-between",
|
|
32
|
-
}}
|
|
33
|
-
>
|
|
34
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: tokens.spacing.sm }}>
|
|
35
|
-
<AtomicIcon name="brush" size="md" color="primary" />
|
|
36
|
-
<AtomicText type="headlineSmall">Adjustments</AtomicText>
|
|
37
|
-
</View>
|
|
38
|
-
<TouchableOpacity
|
|
39
|
-
onPress={handleReset}
|
|
40
|
-
accessibilityLabel="Reset adjustments"
|
|
41
|
-
accessibilityRole="button"
|
|
42
|
-
style={{
|
|
43
|
-
paddingHorizontal: tokens.spacing.md,
|
|
44
|
-
paddingVertical: tokens.spacing.xs,
|
|
45
|
-
backgroundColor: tokens.colors.surfaceVariant,
|
|
46
|
-
borderRadius: tokens.borders.radius.sm,
|
|
47
|
-
}}
|
|
48
|
-
>
|
|
49
|
-
<AtomicText type="labelSmall" color="textSecondary">
|
|
50
|
-
Reset
|
|
51
|
-
</AtomicText>
|
|
52
|
-
</TouchableOpacity>
|
|
53
|
-
</View>
|
|
54
|
-
|
|
55
|
-
<Slider
|
|
56
|
-
label="Brightness"
|
|
57
|
-
value={filters.brightness}
|
|
58
|
-
min={0.5}
|
|
59
|
-
max={2}
|
|
60
|
-
step={0.05}
|
|
61
|
-
onValueChange={(v) => update("brightness", v)}
|
|
62
|
-
formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
|
|
63
|
-
/>
|
|
64
|
-
|
|
65
|
-
<Slider
|
|
66
|
-
label="Contrast"
|
|
67
|
-
value={filters.contrast}
|
|
68
|
-
min={0.5}
|
|
69
|
-
max={2}
|
|
70
|
-
step={0.05}
|
|
71
|
-
onValueChange={(v) => update("contrast", v)}
|
|
72
|
-
formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
|
|
73
|
-
/>
|
|
74
|
-
|
|
75
|
-
<Slider
|
|
76
|
-
label="Saturation"
|
|
77
|
-
value={filters.saturation}
|
|
78
|
-
min={0}
|
|
79
|
-
max={2}
|
|
80
|
-
step={0.05}
|
|
81
|
-
onValueChange={(v) => update("saturation", v)}
|
|
82
|
-
formatValue={(v) => `${Math.round((v - 1) * 100) >= 0 ? "+" : ""}${Math.round((v - 1) * 100)}%`}
|
|
83
|
-
/>
|
|
84
|
-
|
|
85
|
-
<Slider
|
|
86
|
-
label="Hue Rotate"
|
|
87
|
-
value={filters.hueRotate ?? 0}
|
|
88
|
-
min={0}
|
|
89
|
-
max={360}
|
|
90
|
-
step={1}
|
|
91
|
-
onValueChange={(v) => update("hueRotate", v)}
|
|
92
|
-
formatValue={(v) => `${Math.round(v)}°`}
|
|
93
|
-
/>
|
|
94
|
-
|
|
95
|
-
<Slider
|
|
96
|
-
label="Sepia"
|
|
97
|
-
value={filters.sepia}
|
|
98
|
-
min={0}
|
|
99
|
-
max={1}
|
|
100
|
-
step={0.05}
|
|
101
|
-
onValueChange={(v) => update("sepia", v)}
|
|
102
|
-
formatValue={(v) => `${Math.round(v * 100)}%`}
|
|
103
|
-
/>
|
|
104
|
-
</View>
|
|
105
|
-
);
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
export default React.memo(AdjustmentsSheet);
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { View, TouchableOpacity } from "react-native";
|
|
3
|
-
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
4
|
-
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
5
|
-
import { DEFAULT_TEXT_COLORS } from "../constants";
|
|
6
|
-
|
|
7
|
-
interface ColorPickerProps {
|
|
8
|
-
selectedColor: string;
|
|
9
|
-
onSelectColor: (color: string) => void;
|
|
10
|
-
label?: string;
|
|
11
|
-
colors?: readonly string[];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|
15
|
-
selectedColor,
|
|
16
|
-
onSelectColor,
|
|
17
|
-
label,
|
|
18
|
-
colors = DEFAULT_TEXT_COLORS,
|
|
19
|
-
}) => {
|
|
20
|
-
const tokens = useAppDesignTokens();
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<View style={{ gap: tokens.spacing.xs }}>
|
|
24
|
-
{label && (
|
|
25
|
-
<AtomicText type="labelMedium" color="textSecondary">
|
|
26
|
-
{label}
|
|
27
|
-
</AtomicText>
|
|
28
|
-
)}
|
|
29
|
-
<View
|
|
30
|
-
style={{
|
|
31
|
-
flexDirection: "row",
|
|
32
|
-
flexWrap: "wrap",
|
|
33
|
-
gap: tokens.spacing.xs,
|
|
34
|
-
}}
|
|
35
|
-
>
|
|
36
|
-
{colors.map((color) => {
|
|
37
|
-
const isSelected = selectedColor === color;
|
|
38
|
-
return (
|
|
39
|
-
<TouchableOpacity
|
|
40
|
-
key={color}
|
|
41
|
-
onPress={() => onSelectColor(color)}
|
|
42
|
-
accessibilityLabel={`Color ${color}`}
|
|
43
|
-
accessibilityRole="button"
|
|
44
|
-
accessibilityState={{ selected: isSelected }}
|
|
45
|
-
style={{
|
|
46
|
-
width: 34,
|
|
47
|
-
height: 34,
|
|
48
|
-
borderRadius: 17,
|
|
49
|
-
backgroundColor: color,
|
|
50
|
-
borderWidth: isSelected ? 3 : 1.5,
|
|
51
|
-
borderColor: isSelected
|
|
52
|
-
? tokens.colors.primary
|
|
53
|
-
: tokens.colors.border,
|
|
54
|
-
alignItems: "center",
|
|
55
|
-
justifyContent: "center",
|
|
56
|
-
}}
|
|
57
|
-
>
|
|
58
|
-
{isSelected && (
|
|
59
|
-
<View
|
|
60
|
-
style={{
|
|
61
|
-
width: 10,
|
|
62
|
-
height: 10,
|
|
63
|
-
borderRadius: 5,
|
|
64
|
-
backgroundColor:
|
|
65
|
-
color === "#FFFFFF" ? "#000000" : "#FFFFFF",
|
|
66
|
-
}}
|
|
67
|
-
/>
|
|
68
|
-
)}
|
|
69
|
-
</TouchableOpacity>
|
|
70
|
-
);
|
|
71
|
-
})}
|
|
72
|
-
</View>
|
|
73
|
-
</View>
|
|
74
|
-
);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
export default React.memo(ColorPicker);
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import React, { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
-
import { View, StyleSheet } from "react-native";
|
|
3
|
-
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
|
4
|
-
import { Image } from "expo-image";
|
|
5
|
-
import { AtomicText } from "@umituz/react-native-design-system/atoms";
|
|
6
|
-
import { useAppDesignTokens } from "@umituz/react-native-design-system/theme";
|
|
7
|
-
import type { LayerTransform } from "./DraggableText";
|
|
8
|
-
|
|
9
|
-
interface DraggableStickerProps {
|
|
10
|
-
uri: string;
|
|
11
|
-
initialX: number;
|
|
12
|
-
initialY: number;
|
|
13
|
-
rotation?: number;
|
|
14
|
-
scale?: number;
|
|
15
|
-
opacity?: number;
|
|
16
|
-
onTransformEnd: (transform: LayerTransform) => void;
|
|
17
|
-
onPress: () => void;
|
|
18
|
-
isSelected?: boolean;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const isEmojiString = (str: string) =>
|
|
22
|
-
str.length <= 4 && !/^https?:\/\//i.test(str) && !str.startsWith("/");
|
|
23
|
-
|
|
24
|
-
export const DraggableSticker: React.FC<DraggableStickerProps> = ({
|
|
25
|
-
uri,
|
|
26
|
-
initialX,
|
|
27
|
-
initialY,
|
|
28
|
-
rotation: rotationProp = 0,
|
|
29
|
-
scale: scaleProp = 1,
|
|
30
|
-
opacity = 1,
|
|
31
|
-
onTransformEnd,
|
|
32
|
-
onPress,
|
|
33
|
-
isSelected,
|
|
34
|
-
}) => {
|
|
35
|
-
const tokens = useAppDesignTokens();
|
|
36
|
-
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
|
37
|
-
const [scale, setScale] = useState(scaleProp);
|
|
38
|
-
const [rotation, setRotation] = useState(rotationProp);
|
|
39
|
-
|
|
40
|
-
// Sync when props change (e.g., undo/redo)
|
|
41
|
-
useEffect(() => { setPosition({ x: initialX, y: initialY }); }, [initialX, initialY]);
|
|
42
|
-
useEffect(() => { setScale(scaleProp); }, [scaleProp]);
|
|
43
|
-
useEffect(() => { setRotation(rotationProp); }, [rotationProp]);
|
|
44
|
-
|
|
45
|
-
const positionRef = useRef(position);
|
|
46
|
-
positionRef.current = position;
|
|
47
|
-
const scaleRef = useRef(scale);
|
|
48
|
-
scaleRef.current = scale;
|
|
49
|
-
const rotationRef = useRef(rotation);
|
|
50
|
-
rotationRef.current = rotation;
|
|
51
|
-
const onTransformEndRef = useRef(onTransformEnd);
|
|
52
|
-
onTransformEndRef.current = onTransformEnd;
|
|
53
|
-
const onPressRef = useRef(onPress);
|
|
54
|
-
onPressRef.current = onPress;
|
|
55
|
-
|
|
56
|
-
const offsetRef = useRef({ x: initialX, y: initialY });
|
|
57
|
-
const scaleStartRef = useRef(scaleProp);
|
|
58
|
-
const rotationStartRef = useRef(rotationProp);
|
|
59
|
-
|
|
60
|
-
const emitTransform = useCallback(() => {
|
|
61
|
-
onTransformEndRef.current({
|
|
62
|
-
x: positionRef.current.x,
|
|
63
|
-
y: positionRef.current.y,
|
|
64
|
-
scale: scaleRef.current,
|
|
65
|
-
rotation: rotationRef.current,
|
|
66
|
-
});
|
|
67
|
-
}, []);
|
|
68
|
-
|
|
69
|
-
const panGesture = Gesture.Pan()
|
|
70
|
-
.runOnJS(true)
|
|
71
|
-
.averageTouches(true)
|
|
72
|
-
.onStart(() => {
|
|
73
|
-
offsetRef.current = { x: positionRef.current.x, y: positionRef.current.y };
|
|
74
|
-
})
|
|
75
|
-
.onUpdate((e) => {
|
|
76
|
-
setPosition({
|
|
77
|
-
x: offsetRef.current.x + e.translationX,
|
|
78
|
-
y: offsetRef.current.y + e.translationY,
|
|
79
|
-
});
|
|
80
|
-
})
|
|
81
|
-
.onEnd(emitTransform);
|
|
82
|
-
|
|
83
|
-
const pinchGesture = Gesture.Pinch()
|
|
84
|
-
.runOnJS(true)
|
|
85
|
-
.onStart(() => {
|
|
86
|
-
scaleStartRef.current = scaleRef.current;
|
|
87
|
-
})
|
|
88
|
-
.onUpdate((e) => {
|
|
89
|
-
setScale(Math.max(0.2, Math.min(6, scaleStartRef.current * e.scale)));
|
|
90
|
-
})
|
|
91
|
-
.onEnd(emitTransform);
|
|
92
|
-
|
|
93
|
-
const rotationGesture = Gesture.Rotation()
|
|
94
|
-
.runOnJS(true)
|
|
95
|
-
.onStart(() => {
|
|
96
|
-
rotationStartRef.current = rotationRef.current;
|
|
97
|
-
})
|
|
98
|
-
.onUpdate((e) => {
|
|
99
|
-
setRotation(rotationStartRef.current + (e.rotation * 180) / Math.PI);
|
|
100
|
-
})
|
|
101
|
-
.onEnd(emitTransform);
|
|
102
|
-
|
|
103
|
-
const tapGesture = Gesture.Tap()
|
|
104
|
-
.runOnJS(true)
|
|
105
|
-
.onEnd(() => onPressRef.current());
|
|
106
|
-
|
|
107
|
-
const composed = Gesture.Exclusive(
|
|
108
|
-
Gesture.Simultaneous(panGesture, pinchGesture, rotationGesture),
|
|
109
|
-
tapGesture,
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
const isEmoji = isEmojiString(uri);
|
|
113
|
-
|
|
114
|
-
return (
|
|
115
|
-
<GestureDetector gesture={composed}>
|
|
116
|
-
<View
|
|
117
|
-
accessibilityLabel={isEmoji ? `Sticker ${uri}` : "Image sticker"}
|
|
118
|
-
accessibilityRole="button"
|
|
119
|
-
style={[
|
|
120
|
-
styles.container,
|
|
121
|
-
{
|
|
122
|
-
transform: [
|
|
123
|
-
{ translateX: position.x },
|
|
124
|
-
{ translateY: position.y },
|
|
125
|
-
{ rotate: `${rotation}deg` },
|
|
126
|
-
{ scale },
|
|
127
|
-
],
|
|
128
|
-
opacity,
|
|
129
|
-
zIndex: isSelected ? 100 : 50,
|
|
130
|
-
},
|
|
131
|
-
]}
|
|
132
|
-
>
|
|
133
|
-
<View
|
|
134
|
-
style={{
|
|
135
|
-
padding: tokens.spacing.xs,
|
|
136
|
-
borderRadius: tokens.borders.radius.sm,
|
|
137
|
-
borderWidth: isSelected ? 2 : 0,
|
|
138
|
-
borderColor: tokens.colors.primary,
|
|
139
|
-
borderStyle: "dashed",
|
|
140
|
-
backgroundColor: isSelected ? tokens.colors.primary + "10" : "transparent",
|
|
141
|
-
}}
|
|
142
|
-
>
|
|
143
|
-
{isEmoji ? (
|
|
144
|
-
<AtomicText style={{ fontSize: 48 }}>{uri}</AtomicText>
|
|
145
|
-
) : (
|
|
146
|
-
<Image
|
|
147
|
-
source={{ uri }}
|
|
148
|
-
style={{ width: 80, height: 80 }}
|
|
149
|
-
contentFit="contain"
|
|
150
|
-
accessibilityIgnoresInvertColors
|
|
151
|
-
/>
|
|
152
|
-
)}
|
|
153
|
-
</View>
|
|
154
|
-
</View>
|
|
155
|
-
</GestureDetector>
|
|
156
|
-
);
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const styles = StyleSheet.create({
|
|
160
|
-
container: { position: "absolute" },
|
|
161
|
-
});
|