@umituz/react-native-image 1.3.8 → 1.3.10
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 +8 -2
- package/src/index.ts +5 -4
- package/src/infrastructure/services/ImageConversionService.ts +7 -32
- package/src/infrastructure/services/ImageEditorService.ts +41 -179
- package/src/infrastructure/services/{ImageAIEnhancementService.ts → ImageEnhanceService.ts} +42 -55
- package/src/infrastructure/services/ImageTemplateService.ts +10 -4
- package/src/infrastructure/services/ImageTransformService.ts +43 -93
- package/src/infrastructure/utils/FilterProcessor.ts +26 -263
- package/src/infrastructure/utils/{AIImageAnalysisUtils.ts → ImageAnalysisUtils.ts} +3 -3
- package/src/infrastructure/utils/ImageEditorHistoryUtils.ts +63 -0
- package/src/infrastructure/utils/ImageFilterUtils.ts +152 -0
- package/src/infrastructure/utils/ImageTransformUtils.ts +25 -0
- package/src/infrastructure/utils/LayerManager.ts +0 -81
- package/src/presentation/components/editor/FilterPickerSheet.tsx +75 -0
- package/src/presentation/components/editor/StickerPickerSheet.tsx +62 -0
- package/src/presentation/components/editor/TextEditorSheet.tsx +98 -0
- package/src/presentation/components/editor/TextEditorTabs.tsx +111 -0
- package/src/presentation/hooks/useImage.ts +5 -8
- package/src/presentation/hooks/useImageEnhance.ts +32 -0
- package/src/presentation/hooks/useImageTransform.ts +3 -4
- package/src/infrastructure/services/ImageAdvancedTransformService.ts +0 -106
- package/src/infrastructure/services/ImageSpecializedEnhancementService.ts +0 -57
- package/src/presentation/hooks/useImageAIEnhancement.ts +0 -33
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure - Filter Utils
|
|
3
|
+
*
|
|
4
|
+
* Low-level filter implementations for raw pixel data
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class ImageFilterUtils {
|
|
8
|
+
static applyBrightness(
|
|
9
|
+
data: Uint8ClampedArray,
|
|
10
|
+
brightness: number
|
|
11
|
+
): Uint8ClampedArray {
|
|
12
|
+
const result = new Uint8ClampedArray(data);
|
|
13
|
+
const adjustment = brightness * 2.55;
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < result.length; i += 4) {
|
|
16
|
+
result[i] = Math.min(255, Math.max(0, result[i] + adjustment));
|
|
17
|
+
result[i + 1] = Math.min(255, Math.max(0, result[i + 1] + adjustment));
|
|
18
|
+
result[i + 2] = Math.min(255, Math.max(0, result[i + 2] + adjustment));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static applyContrast(
|
|
25
|
+
data: Uint8ClampedArray,
|
|
26
|
+
contrast: number
|
|
27
|
+
): Uint8ClampedArray {
|
|
28
|
+
const result = new Uint8ClampedArray(data);
|
|
29
|
+
const factor = (259 * (contrast * 2.55 + 255)) / (255 * (259 - contrast * 2.55));
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < result.length; i += 4) {
|
|
32
|
+
result[i] = Math.min(255, Math.max(0, factor * (result[i] - 128) + 128));
|
|
33
|
+
result[i + 1] = Math.min(255, Math.max(0, factor * (result[i + 1] - 128) + 128));
|
|
34
|
+
result[i + 2] = Math.min(255, Math.max(0, factor * (result[i + 2] - 128) + 128));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static applySaturation(
|
|
41
|
+
data: Uint8ClampedArray,
|
|
42
|
+
saturation: number
|
|
43
|
+
): Uint8ClampedArray {
|
|
44
|
+
const result = new Uint8ClampedArray(data);
|
|
45
|
+
const adjustment = 1 + (saturation / 100);
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < result.length; i += 4) {
|
|
48
|
+
const r = result[i];
|
|
49
|
+
const g = result[i + 1];
|
|
50
|
+
const b = result[i + 2];
|
|
51
|
+
|
|
52
|
+
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
53
|
+
|
|
54
|
+
result[i] = Math.min(255, Math.max(0, gray + adjustment * (r - gray)));
|
|
55
|
+
result[i + 1] = Math.min(255, Math.max(0, gray + adjustment * (g - gray)));
|
|
56
|
+
result[i + 2] = Math.min(255, Math.max(0, gray + adjustment * (b - gray)));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static applyVintage(
|
|
63
|
+
data: Uint8ClampedArray,
|
|
64
|
+
intensity: number,
|
|
65
|
+
warmth: number
|
|
66
|
+
): Uint8ClampedArray {
|
|
67
|
+
const result = new Uint8ClampedArray(data);
|
|
68
|
+
const factor = intensity / 100;
|
|
69
|
+
const warmFactor = warmth / 100;
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < result.length; i += 4) {
|
|
72
|
+
let r = result[i];
|
|
73
|
+
let g = result[i + 1];
|
|
74
|
+
let b = result[i + 2];
|
|
75
|
+
|
|
76
|
+
const tr = 0.393 * r + 0.769 * g + 0.189 * b;
|
|
77
|
+
const tg = 0.349 * r + 0.686 * g + 0.168 * b;
|
|
78
|
+
const tb = 0.272 * r + 0.534 * g + 0.131 * b;
|
|
79
|
+
|
|
80
|
+
r = r * (1 - factor) + tr * factor;
|
|
81
|
+
g = g * (1 - factor) + tg * factor;
|
|
82
|
+
b = b * (1 - factor) + tb * factor;
|
|
83
|
+
|
|
84
|
+
if (warmFactor > 0) {
|
|
85
|
+
result[i] = Math.min(255, r + warmFactor * 20);
|
|
86
|
+
result[i + 1] = Math.min(255, g + warmFactor * 10);
|
|
87
|
+
result[i + 2] = Math.min(255, b * (1 - warmFactor * 0.3));
|
|
88
|
+
} else {
|
|
89
|
+
result[i] = r;
|
|
90
|
+
result[i + 1] = g;
|
|
91
|
+
result[i + 2] = Math.min(255, b * (1 - Math.abs(warmFactor) * 0.3));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static applyBlur(
|
|
99
|
+
data: Uint8ClampedArray,
|
|
100
|
+
radius: number,
|
|
101
|
+
width: number,
|
|
102
|
+
height: number
|
|
103
|
+
): Uint8ClampedArray {
|
|
104
|
+
const result = new Uint8ClampedArray(data);
|
|
105
|
+
const size = Math.floor(radius) || 1;
|
|
106
|
+
|
|
107
|
+
for (let y = 0; y < height; y++) {
|
|
108
|
+
for (let x = 0; x < width; x++) {
|
|
109
|
+
let r = 0, g = 0, b = 0, a = 0;
|
|
110
|
+
let count = 0;
|
|
111
|
+
|
|
112
|
+
for (let dy = -size; dy <= size; dy++) {
|
|
113
|
+
for (let dx = -size; dx <= size; dx++) {
|
|
114
|
+
const ny = y + dy;
|
|
115
|
+
const nx = x + dx;
|
|
116
|
+
|
|
117
|
+
if (ny >= 0 && ny < height && nx >= 0 && nx < width) {
|
|
118
|
+
const idx = (ny * width + nx) * 4;
|
|
119
|
+
r += data[idx];
|
|
120
|
+
g += data[idx + 1];
|
|
121
|
+
b += data[idx + 2];
|
|
122
|
+
a += data[idx + 3];
|
|
123
|
+
count++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const idx = (y * width + x) * 4;
|
|
129
|
+
result[idx] = r / count;
|
|
130
|
+
result[idx + 1] = g / count;
|
|
131
|
+
result[idx + 2] = b / count;
|
|
132
|
+
result[idx + 3] = a / count;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static applyIntensity(
|
|
140
|
+
originalData: Uint8ClampedArray,
|
|
141
|
+
processedData: Uint8ClampedArray,
|
|
142
|
+
intensity: number
|
|
143
|
+
): Uint8ClampedArray {
|
|
144
|
+
const result = new Uint8ClampedArray(originalData.length);
|
|
145
|
+
|
|
146
|
+
for (let i = 0; i < originalData.length; i++) {
|
|
147
|
+
result[i] = originalData[i] * (1 - intensity) + processedData[i] * intensity;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure - Transform Utils
|
|
3
|
+
*
|
|
4
|
+
* Internal utilities for image transformations
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as ImageManipulator from 'expo-image-manipulator';
|
|
8
|
+
import type { SaveFormat, ImageSaveOptions } from '../../domain/entities/ImageTypes';
|
|
9
|
+
import { IMAGE_CONSTANTS } from '../../domain/entities/ImageConstants';
|
|
10
|
+
|
|
11
|
+
export class ImageTransformUtils {
|
|
12
|
+
static mapFormat(format?: SaveFormat): ImageManipulator.SaveFormat {
|
|
13
|
+
if (format === 'png') return ImageManipulator.SaveFormat.PNG;
|
|
14
|
+
if (format === 'webp') return ImageManipulator.SaveFormat.WEBP;
|
|
15
|
+
return ImageManipulator.SaveFormat.JPEG;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static buildSaveOptions(options?: ImageSaveOptions): ImageManipulator.SaveOptions {
|
|
19
|
+
return {
|
|
20
|
+
compress: options?.compress ?? IMAGE_CONSTANTS.defaultQuality,
|
|
21
|
+
format: this.mapFormat(options?.format),
|
|
22
|
+
base64: options?.base64,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -13,44 +13,7 @@ export interface LayerComposition {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export class LayerManager {
|
|
16
|
-
static composeLayers(
|
|
17
|
-
layers: Array<{
|
|
18
|
-
canvas: HTMLCanvasElement | any;
|
|
19
|
-
opacity: number;
|
|
20
|
-
visible: boolean;
|
|
21
|
-
x?: number;
|
|
22
|
-
y?: number;
|
|
23
|
-
}>,
|
|
24
|
-
composition: LayerComposition
|
|
25
|
-
): HTMLCanvasElement | any {
|
|
26
|
-
// Create composition canvas
|
|
27
|
-
const composedCanvas = document.createElement('canvas') || {} as any;
|
|
28
|
-
composedCanvas.width = composition.width;
|
|
29
|
-
composedCanvas.height = composition.height;
|
|
30
|
-
const ctx = composedCanvas.getContext('2d');
|
|
31
|
-
|
|
32
|
-
if (!ctx) return composedCanvas;
|
|
33
|
-
|
|
34
|
-
// Clear canvas with background color
|
|
35
|
-
ctx.fillStyle = composition.backgroundColor || '#ffffff';
|
|
36
|
-
ctx.fillRect(0, 0, composition.width, composition.height);
|
|
37
|
-
|
|
38
|
-
// Draw each layer
|
|
39
|
-
layers.forEach(layer => {
|
|
40
|
-
if (!layer.visible) return;
|
|
41
16
|
|
|
42
|
-
ctx.save();
|
|
43
|
-
ctx.globalAlpha = layer.opacity;
|
|
44
|
-
ctx.drawImage(
|
|
45
|
-
layer.canvas,
|
|
46
|
-
layer.x || 0,
|
|
47
|
-
layer.y || 0
|
|
48
|
-
);
|
|
49
|
-
ctx.restore();
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
return composedCanvas;
|
|
53
|
-
}
|
|
54
17
|
|
|
55
18
|
static mergeLayers(
|
|
56
19
|
layers: Array<{
|
|
@@ -110,49 +73,5 @@ export class LayerManager {
|
|
|
110
73
|
return result.map((layer, index) => ({ ...layer, index }));
|
|
111
74
|
}
|
|
112
75
|
|
|
113
|
-
static flattenLayers(
|
|
114
|
-
layers: Array<{
|
|
115
|
-
canvas: HTMLCanvasElement | any;
|
|
116
|
-
opacity: number;
|
|
117
|
-
visible: boolean;
|
|
118
|
-
}>,
|
|
119
|
-
composition: LayerComposition
|
|
120
|
-
): HTMLCanvasElement | any {
|
|
121
|
-
return LayerManager.composeLayers(layers, composition);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
static createLayerCanvas(
|
|
125
|
-
width: number,
|
|
126
|
-
height: number,
|
|
127
|
-
transparent: boolean = true
|
|
128
|
-
): HTMLCanvasElement | any {
|
|
129
|
-
const canvas = document.createElement('canvas') || {} as any;
|
|
130
|
-
canvas.width = width;
|
|
131
|
-
canvas.height = height;
|
|
132
|
-
|
|
133
|
-
if (!transparent) {
|
|
134
|
-
const ctx = canvas.getContext('2d');
|
|
135
|
-
if (ctx) {
|
|
136
|
-
ctx.fillStyle = '#ffffff';
|
|
137
|
-
ctx.fillRect(0, 0, width, height);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
76
|
|
|
141
|
-
return canvas;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
static clearLayer(
|
|
145
|
-
canvas: HTMLCanvasElement | any,
|
|
146
|
-
preserveAlpha: boolean = true
|
|
147
|
-
): void {
|
|
148
|
-
const ctx = canvas.getContext('2d');
|
|
149
|
-
if (!ctx) return;
|
|
150
|
-
|
|
151
|
-
if (preserveAlpha) {
|
|
152
|
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
153
|
-
} else {
|
|
154
|
-
ctx.fillStyle = 'transparent';
|
|
155
|
-
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
77
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation - Filter Picker Sheet
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { forwardRef } from 'react';
|
|
6
|
+
import { View, TouchableOpacity, ScrollView } from 'react-native';
|
|
7
|
+
import {
|
|
8
|
+
BottomSheetModal,
|
|
9
|
+
AtomicText,
|
|
10
|
+
AtomicIcon,
|
|
11
|
+
useAppDesignTokens,
|
|
12
|
+
BottomSheetModalRef
|
|
13
|
+
} from '@umituz/react-native-design-system';
|
|
14
|
+
import { FilterProcessor, type FilterPreset } from '../../../infrastructure/utils/FilterProcessor';
|
|
15
|
+
|
|
16
|
+
export interface FilterPickerSheetProps {
|
|
17
|
+
onSelectFilter: (filterId: string) => void;
|
|
18
|
+
onDismiss: () => void;
|
|
19
|
+
activeFilterId?: string;
|
|
20
|
+
snapPoints?: string[];
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const FilterPickerSheet = forwardRef<BottomSheetModalRef, FilterPickerSheetProps>(
|
|
25
|
+
({ onSelectFilter, onDismiss, activeFilterId, snapPoints = ['50%'], title = 'Filters' }, ref) => {
|
|
26
|
+
const tokens = useAppDesignTokens();
|
|
27
|
+
const presets = FilterProcessor.getPresets();
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<BottomSheetModal ref={ref} snapPoints={snapPoints} onDismiss={onDismiss}>
|
|
31
|
+
<View style={{ padding: tokens.spacing.lg, flex: 1 }}>
|
|
32
|
+
<AtomicText style={{ ...tokens.typography.headingSmall, marginBottom: tokens.spacing.md }}>
|
|
33
|
+
{title}
|
|
34
|
+
</AtomicText>
|
|
35
|
+
|
|
36
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
37
|
+
<View style={{ flexDirection: 'row', gap: tokens.spacing.md }}>
|
|
38
|
+
{presets.map((preset: FilterPreset) => (
|
|
39
|
+
<TouchableOpacity
|
|
40
|
+
key={preset.id}
|
|
41
|
+
onPress={() => onSelectFilter(preset.id)}
|
|
42
|
+
style={{
|
|
43
|
+
width: 100,
|
|
44
|
+
height: 120,
|
|
45
|
+
backgroundColor: activeFilterId === preset.id ? tokens.colors.primaryContainer : tokens.colors.surfaceVariant,
|
|
46
|
+
borderRadius: tokens.borderRadius.lg,
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
borderWidth: 2,
|
|
50
|
+
borderColor: activeFilterId === preset.id ? tokens.colors.primary : 'transparent',
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<AtomicIcon
|
|
54
|
+
name="color-filter"
|
|
55
|
+
size={32}
|
|
56
|
+
color={activeFilterId === preset.id ? 'primary' : 'secondary'}
|
|
57
|
+
/>
|
|
58
|
+
<AtomicText style={{
|
|
59
|
+
...tokens.typography.labelSmall,
|
|
60
|
+
marginTop: tokens.spacing.sm,
|
|
61
|
+
color: activeFilterId === preset.id ? tokens.colors.primary : tokens.colors.textSecondary
|
|
62
|
+
}}>
|
|
63
|
+
{preset.name}
|
|
64
|
+
</AtomicText>
|
|
65
|
+
</TouchableOpacity>
|
|
66
|
+
))}
|
|
67
|
+
</View>
|
|
68
|
+
</ScrollView>
|
|
69
|
+
</View>
|
|
70
|
+
</BottomSheetModal>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
FilterPickerSheet.displayName = 'FilterPickerSheet';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation - Sticker Picker Sheet
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { forwardRef } from 'react';
|
|
6
|
+
import { View, TouchableOpacity, ScrollView, Image, Dimensions } from 'react-native';
|
|
7
|
+
import {
|
|
8
|
+
BottomSheetModal,
|
|
9
|
+
AtomicText,
|
|
10
|
+
useAppDesignTokens,
|
|
11
|
+
BottomSheetModalRef
|
|
12
|
+
} from '@umituz/react-native-design-system';
|
|
13
|
+
|
|
14
|
+
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
15
|
+
|
|
16
|
+
export interface StickerPickerSheetProps {
|
|
17
|
+
stickers: string[];
|
|
18
|
+
onSelectSticker: (uri: string) => void;
|
|
19
|
+
onDismiss: () => void;
|
|
20
|
+
title?: string;
|
|
21
|
+
snapPoints?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const StickerPickerSheet = forwardRef<BottomSheetModalRef, StickerPickerSheetProps>(
|
|
25
|
+
({ stickers, onSelectSticker, onDismiss, title = 'Select Sticker', snapPoints = ['60%'] }, ref) => {
|
|
26
|
+
const tokens = useAppDesignTokens();
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<BottomSheetModal ref={ref} snapPoints={snapPoints} onDismiss={onDismiss}>
|
|
30
|
+
<View style={{ padding: tokens.spacing.lg, flex: 1 }}>
|
|
31
|
+
<AtomicText style={{ ...tokens.typography.headingSmall, marginBottom: tokens.spacing.md }}>
|
|
32
|
+
{title}
|
|
33
|
+
</AtomicText>
|
|
34
|
+
|
|
35
|
+
<ScrollView showsVerticalScrollIndicator={false}>
|
|
36
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: tokens.spacing.md }}>
|
|
37
|
+
{stickers.map((uri, index) => (
|
|
38
|
+
<TouchableOpacity
|
|
39
|
+
key={index}
|
|
40
|
+
onPress={() => onSelectSticker(uri)}
|
|
41
|
+
style={{
|
|
42
|
+
width: (SCREEN_WIDTH - 64) / 3,
|
|
43
|
+
aspectRatio: 1,
|
|
44
|
+
backgroundColor: tokens.colors.surfaceVariant,
|
|
45
|
+
borderRadius: tokens.borderRadius.md,
|
|
46
|
+
padding: tokens.spacing.sm,
|
|
47
|
+
alignItems: 'center',
|
|
48
|
+
justifyContent: 'center',
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<Image source={{ uri }} style={{ width: '100%', height: '100%' }} resizeMode="contain" />
|
|
52
|
+
</TouchableOpacity>
|
|
53
|
+
))}
|
|
54
|
+
</View>
|
|
55
|
+
</ScrollView>
|
|
56
|
+
</View>
|
|
57
|
+
</BottomSheetModal>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
StickerPickerSheet.displayName = 'StickerPickerSheet';
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation - Text Editor Sheet
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React, { forwardRef, useState } from 'react';
|
|
6
|
+
import { View, TouchableOpacity, ScrollView } from 'react-native';
|
|
7
|
+
import {
|
|
8
|
+
BottomSheetModal,
|
|
9
|
+
AtomicText,
|
|
10
|
+
AtomicIcon,
|
|
11
|
+
useAppDesignTokens,
|
|
12
|
+
BottomSheetModalRef
|
|
13
|
+
} from '@umituz/react-native-design-system';
|
|
14
|
+
import { TextContentTab, TextStyleTab, TextTransformTab } from './TextEditorTabs';
|
|
15
|
+
|
|
16
|
+
export interface TextEditorSheetProps {
|
|
17
|
+
text: string;
|
|
18
|
+
onTextChange: (text: string) => void;
|
|
19
|
+
color: string;
|
|
20
|
+
onColorChange: (color: string) => void;
|
|
21
|
+
fontSize: number;
|
|
22
|
+
onFontSizeChange: (size: number) => void;
|
|
23
|
+
fontFamily: string;
|
|
24
|
+
onFontFamilyChange: (font: string) => void;
|
|
25
|
+
scale: number;
|
|
26
|
+
onScaleChange: (scale: number) => void;
|
|
27
|
+
rotation: number;
|
|
28
|
+
onRotationChange: (rotation: number) => void;
|
|
29
|
+
opacity: number;
|
|
30
|
+
onOpacityChange: (opacity: number) => void;
|
|
31
|
+
onDelete?: () => void;
|
|
32
|
+
onDismiss: () => void;
|
|
33
|
+
snapPoints?: string[];
|
|
34
|
+
t: (key: string) => string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const TextEditorSheet = forwardRef<BottomSheetModalRef, TextEditorSheetProps>((props, ref) => {
|
|
38
|
+
const tokens = useAppDesignTokens();
|
|
39
|
+
const [activeTab, setActiveTab] = useState<'content' | 'style' | 'transform'>('content');
|
|
40
|
+
const { onDismiss, snapPoints = ['75%'], t } = props;
|
|
41
|
+
|
|
42
|
+
const tabs = [
|
|
43
|
+
{ id: 'content', label: 'Text', icon: 'text' },
|
|
44
|
+
{ id: 'style', label: 'Style', icon: 'color-palette' },
|
|
45
|
+
{ id: 'transform', label: 'Edit', icon: 'options' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<BottomSheetModal ref={ref} snapPoints={snapPoints} onDismiss={onDismiss}>
|
|
50
|
+
<View style={{ padding: tokens.spacing.lg, flex: 1 }}>
|
|
51
|
+
<View style={{ flexDirection: 'row', gap: tokens.spacing.md, marginBottom: tokens.spacing.lg }}>
|
|
52
|
+
{tabs.map(tab => (
|
|
53
|
+
<TouchableOpacity
|
|
54
|
+
key={tab.id}
|
|
55
|
+
onPress={() => setActiveTab(tab.id as any)}
|
|
56
|
+
style={{
|
|
57
|
+
flex: 1, alignItems: 'center', paddingVertical: tokens.spacing.sm,
|
|
58
|
+
borderBottomWidth: 3, borderBottomColor: activeTab === tab.id ? tokens.colors.primary : 'transparent'
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<AtomicIcon name={tab.icon as any} size={20} color={activeTab === tab.id ? 'primary' : 'secondary'} />
|
|
62
|
+
<AtomicText style={{
|
|
63
|
+
...tokens.typography.labelSmall,
|
|
64
|
+
color: activeTab === tab.id ? tokens.colors.primary : tokens.colors.textSecondary,
|
|
65
|
+
marginTop: 4
|
|
66
|
+
}}>
|
|
67
|
+
{tab.label}
|
|
68
|
+
</AtomicText>
|
|
69
|
+
</TouchableOpacity>
|
|
70
|
+
))}
|
|
71
|
+
</View>
|
|
72
|
+
|
|
73
|
+
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 60 }}>
|
|
74
|
+
{activeTab === 'content' && <TextContentTab text={props.text} onTextChange={props.onTextChange} t={t} />}
|
|
75
|
+
{activeTab === 'style' && (
|
|
76
|
+
<TextStyleTab
|
|
77
|
+
fontSize={props.fontSize} setFontSize={props.onFontSizeChange}
|
|
78
|
+
color={props.color} setColor={props.onColorChange}
|
|
79
|
+
fontFamily={props.fontFamily} setFontFamily={props.onFontFamilyChange}
|
|
80
|
+
t={t}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
{activeTab === 'transform' && (
|
|
84
|
+
<TextTransformTab
|
|
85
|
+
scale={props.scale} setScale={props.onScaleChange}
|
|
86
|
+
rotation={props.rotation} setRotation={props.onRotationChange}
|
|
87
|
+
opacity={props.opacity} setOpacity={props.onOpacityChange}
|
|
88
|
+
onDelete={props.onDelete}
|
|
89
|
+
t={t}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</ScrollView>
|
|
93
|
+
</View>
|
|
94
|
+
</BottomSheetModal>
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
TextEditorSheet.displayName = 'TextEditorSheet';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation - Text Editor Tabs
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { View, TextInput, ScrollView, TouchableOpacity } from 'react-native';
|
|
7
|
+
import Slider from '@react-native-community/slider';
|
|
8
|
+
import { AtomicText, AtomicIcon, useAppDesignTokens } from '@umituz/react-native-design-system';
|
|
9
|
+
|
|
10
|
+
interface TabProps {
|
|
11
|
+
t: (key: string) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const TextContentTab: React.FC<TabProps & { text: string; onTextChange: (t: string) => void }> = ({ text, onTextChange, t }) => {
|
|
15
|
+
const tokens = useAppDesignTokens();
|
|
16
|
+
return (
|
|
17
|
+
<View style={{ gap: tokens.spacing.lg }}>
|
|
18
|
+
<TextInput
|
|
19
|
+
value={text}
|
|
20
|
+
onChangeText={onTextChange}
|
|
21
|
+
placeholder={t('editor.text_placeholder')}
|
|
22
|
+
style={{
|
|
23
|
+
...tokens.typography.bodyLarge,
|
|
24
|
+
borderWidth: 1,
|
|
25
|
+
borderColor: tokens.colors.border,
|
|
26
|
+
borderRadius: tokens.borderRadius.md,
|
|
27
|
+
padding: tokens.spacing.md,
|
|
28
|
+
minHeight: 120,
|
|
29
|
+
textAlignVertical: 'top',
|
|
30
|
+
}}
|
|
31
|
+
multiline
|
|
32
|
+
/>
|
|
33
|
+
</View>
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const TextStyleTab: React.FC<TabProps & {
|
|
38
|
+
fontSize: number; setFontSize: (s: number) => void;
|
|
39
|
+
color: string; setColor: (c: string) => void;
|
|
40
|
+
fontFamily: string; setFontFamily: (f: string) => void;
|
|
41
|
+
}> = ({ fontSize, setFontSize, color, setColor, fontFamily, setFontFamily, t }) => {
|
|
42
|
+
const tokens = useAppDesignTokens();
|
|
43
|
+
const colors = ['#FFFFFF', '#000000', '#FF0000', '#FFFF00', '#0000FF', '#00FF00', '#FF00FF', '#FFA500'];
|
|
44
|
+
const fonts = ['System', 'serif', 'sans-serif', 'monospace'];
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={{ gap: tokens.spacing.xl }}>
|
|
48
|
+
<View>
|
|
49
|
+
<AtomicText style={{ ...tokens.typography.labelMedium, marginBottom: tokens.spacing.sm }}>Font</AtomicText>
|
|
50
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: tokens.spacing.sm }}>
|
|
51
|
+
{fonts.map(f => (
|
|
52
|
+
<TouchableOpacity key={f} onPress={() => setFontFamily(f)} style={{
|
|
53
|
+
paddingHorizontal: tokens.spacing.md, paddingVertical: tokens.spacing.xs, borderRadius: tokens.borderRadius.full,
|
|
54
|
+
borderWidth: 1, borderColor: fontFamily === f ? tokens.colors.primary : tokens.colors.border,
|
|
55
|
+
backgroundColor: fontFamily === f ? tokens.colors.primaryContainer : tokens.colors.surface
|
|
56
|
+
}}>
|
|
57
|
+
<AtomicText style={{ fontFamily: f }}>{f}</AtomicText>
|
|
58
|
+
</TouchableOpacity>
|
|
59
|
+
))}
|
|
60
|
+
</ScrollView>
|
|
61
|
+
</View>
|
|
62
|
+
|
|
63
|
+
<View>
|
|
64
|
+
<AtomicText style={{ ...tokens.typography.labelMedium, marginBottom: tokens.spacing.sm }}>Color</AtomicText>
|
|
65
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: tokens.spacing.sm }}>
|
|
66
|
+
{colors.map(c => (
|
|
67
|
+
<TouchableOpacity key={c} onPress={() => setColor(c)} style={{
|
|
68
|
+
width: 40, height: 40, borderRadius: 20, backgroundColor: c,
|
|
69
|
+
borderWidth: color === c ? 3 : 1, borderColor: tokens.colors.primary
|
|
70
|
+
}} />
|
|
71
|
+
))}
|
|
72
|
+
</ScrollView>
|
|
73
|
+
</View>
|
|
74
|
+
|
|
75
|
+
<View>
|
|
76
|
+
<AtomicText style={{ ...tokens.typography.labelMedium, marginBottom: tokens.spacing.xs }}>Size: {fontSize}px</AtomicText>
|
|
77
|
+
<Slider value={fontSize} onValueChange={setFontSize} minimumValue={12} maximumValue={100} step={1} minimumTrackTintColor={tokens.colors.primary} />
|
|
78
|
+
</View>
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const TextTransformTab: React.FC<TabProps & {
|
|
84
|
+
scale: number; setScale: (s: number) => void;
|
|
85
|
+
rotation: number; setRotation: (r: number) => void;
|
|
86
|
+
opacity: number; setOpacity: (o: number) => void;
|
|
87
|
+
onDelete?: () => void;
|
|
88
|
+
}> = ({ scale, setScale, rotation, setRotation, opacity, setOpacity, onDelete, t }) => {
|
|
89
|
+
const tokens = useAppDesignTokens();
|
|
90
|
+
return (
|
|
91
|
+
<View style={{ gap: tokens.spacing.xl }}>
|
|
92
|
+
<View>
|
|
93
|
+
<AtomicText style={{ ...tokens.typography.labelMedium, marginBottom: tokens.spacing.xs }}>Scale: {scale.toFixed(2)}x</AtomicText>
|
|
94
|
+
<Slider value={scale} onValueChange={setScale} minimumValue={0.2} maximumValue={3} step={0.1} minimumTrackTintColor={tokens.colors.primary} />
|
|
95
|
+
</View>
|
|
96
|
+
<View>
|
|
97
|
+
<AtomicText style={{ ...tokens.typography.labelMedium, marginBottom: tokens.spacing.xs }}>Rotation: {Math.round(rotation)}°</AtomicText>
|
|
98
|
+
<Slider value={rotation} onValueChange={setRotation} minimumValue={0} maximumValue={360} step={1} minimumTrackTintColor={tokens.colors.primary} />
|
|
99
|
+
</View>
|
|
100
|
+
{onDelete && (
|
|
101
|
+
<TouchableOpacity onPress={onDelete} style={{
|
|
102
|
+
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: tokens.spacing.sm,
|
|
103
|
+
padding: tokens.spacing.md, borderRadius: tokens.borderRadius.md, borderWidth: 1, borderColor: tokens.colors.error
|
|
104
|
+
}}>
|
|
105
|
+
<AtomicIcon name="trash" size={20} color="error" />
|
|
106
|
+
<AtomicText style={{ ...tokens.typography.labelMedium, color: tokens.colors.error }}>Delete Layer</AtomicText>
|
|
107
|
+
</TouchableOpacity>
|
|
108
|
+
)}
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
@@ -7,36 +7,33 @@
|
|
|
7
7
|
import { useImageTransform } from './useImageTransform';
|
|
8
8
|
import { useImageConversion } from './useImageConversion';
|
|
9
9
|
import { useImageBatch } from './useImageBatch';
|
|
10
|
-
import {
|
|
10
|
+
import { useImageEnhance } from './useImageEnhance';
|
|
11
11
|
import { useImageMetadata } from './useImageMetadata';
|
|
12
12
|
|
|
13
13
|
export const useImage = () => {
|
|
14
14
|
const transform = useImageTransform();
|
|
15
15
|
const conversion = useImageConversion();
|
|
16
16
|
const batch = useImageBatch();
|
|
17
|
-
const
|
|
17
|
+
const enhance = useImageEnhance();
|
|
18
18
|
const metadata = useImageMetadata();
|
|
19
19
|
|
|
20
20
|
return {
|
|
21
|
-
// Basic operations
|
|
22
21
|
...transform,
|
|
23
22
|
...conversion,
|
|
24
|
-
// Advanced operations
|
|
25
23
|
...batch,
|
|
26
|
-
...
|
|
24
|
+
...enhance,
|
|
27
25
|
...metadata,
|
|
28
|
-
// Combined state
|
|
29
26
|
isProcessing:
|
|
30
27
|
transform.isTransforming ||
|
|
31
28
|
conversion.isConverting ||
|
|
32
29
|
batch.isBatchProcessing ||
|
|
33
|
-
|
|
30
|
+
enhance.isEnhancing ||
|
|
34
31
|
metadata.isExtracting,
|
|
35
32
|
error:
|
|
36
33
|
transform.transformError ||
|
|
37
34
|
conversion.conversionError ||
|
|
38
35
|
batch.batchError ||
|
|
39
|
-
|
|
36
|
+
enhance.enhancementError ||
|
|
40
37
|
metadata.metadataError,
|
|
41
38
|
};
|
|
42
39
|
};
|