@umituz/react-native-image 1.1.3 → 1.1.5
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 +2 -2
- package/src/domain/entities/ImageConstants.ts +32 -15
- package/src/domain/entities/ImageTypes.ts +22 -7
- package/src/domain/utils/ImageUtils.ts +6 -6
- package/src/index.ts +4 -0
- package/src/infrastructure/services/ImageAdvancedTransformService.ts +106 -0
- package/src/infrastructure/services/ImageConversionService.ts +51 -18
- package/src/infrastructure/services/ImageStorageService.ts +22 -7
- package/src/infrastructure/services/ImageTransformService.ts +68 -101
- package/src/infrastructure/services/ImageViewerService.ts +3 -28
- package/src/infrastructure/utils/ImageErrorHandler.ts +40 -0
- package/src/infrastructure/utils/ImageValidator.ts +59 -0
- package/src/presentation/components/GalleryHeader.tsx +14 -8
- package/src/presentation/components/ImageGallery.tsx +8 -22
- package/src/presentation/hooks/useImage.ts +2 -3
- package/src/presentation/hooks/useImageConversion.ts +6 -3
- package/src/presentation/hooks/useImageEditor.ts +5 -11
- package/src/presentation/hooks/useImageGallery.ts +1 -60
- package/src/presentation/hooks/useImageOperation.ts +14 -10
- package/src/presentation/hooks/useImageTransform.ts +13 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-image",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.5",
|
|
4
4
|
"description": "Image manipulation and viewing for React Native apps - resize, crop, rotate, flip, compress, gallery viewer",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -56,4 +56,4 @@
|
|
|
56
56
|
"README.md",
|
|
57
57
|
"LICENSE"
|
|
58
58
|
]
|
|
59
|
-
}
|
|
59
|
+
}
|
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image Constants
|
|
2
|
+
* Image Domain - Configuration Constants
|
|
3
3
|
*/
|
|
4
4
|
import { SaveFormat } from "./ImageTypes";
|
|
5
5
|
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
export interface ImageConfiguration {
|
|
7
|
+
readonly maxWidth: number;
|
|
8
|
+
readonly maxHeight: number;
|
|
9
|
+
readonly defaultQuality: number;
|
|
10
|
+
readonly thumbnailSize: number;
|
|
11
|
+
readonly compressQuality: {
|
|
12
|
+
readonly low: number;
|
|
13
|
+
readonly medium: number;
|
|
14
|
+
readonly high: number;
|
|
15
|
+
};
|
|
16
|
+
readonly format: {
|
|
17
|
+
readonly jpeg: SaveFormat;
|
|
18
|
+
readonly png: SaveFormat;
|
|
19
|
+
readonly webp: SaveFormat;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const IMAGE_CONSTANTS: ImageConfiguration = {
|
|
24
|
+
maxWidth: 2048,
|
|
25
|
+
maxHeight: 2048,
|
|
26
|
+
defaultQuality: 0.8,
|
|
27
|
+
thumbnailSize: 200,
|
|
28
|
+
compressQuality: {
|
|
29
|
+
low: 0.5,
|
|
30
|
+
medium: 0.7,
|
|
31
|
+
high: 0.9,
|
|
15
32
|
},
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
format: {
|
|
34
|
+
jpeg: 'jpeg' as SaveFormat,
|
|
35
|
+
png: 'png' as SaveFormat,
|
|
36
|
+
webp: 'webp' as SaveFormat,
|
|
20
37
|
},
|
|
21
|
-
}
|
|
38
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image Type Definitions
|
|
2
|
+
* Image Domain - Core Type Definitions
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export enum ImageFormat {
|
|
@@ -16,11 +16,28 @@ export enum ImageOrientation {
|
|
|
16
16
|
SQUARE = 'square',
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export interface ImageDimensions {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ImageCropArea {
|
|
25
|
+
originX: number;
|
|
26
|
+
originY: number;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ImageFlipOptions {
|
|
32
|
+
vertical?: boolean;
|
|
33
|
+
horizontal?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
export interface ImageManipulateAction {
|
|
20
|
-
resize?:
|
|
21
|
-
crop?:
|
|
37
|
+
resize?: Partial<ImageDimensions>;
|
|
38
|
+
crop?: ImageCropArea;
|
|
22
39
|
rotate?: number;
|
|
23
|
-
flip?:
|
|
40
|
+
flip?: ImageFlipOptions;
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
export interface ImageSaveOptions {
|
|
@@ -36,10 +53,8 @@ export interface ImageManipulationResult {
|
|
|
36
53
|
base64?: string;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
export interface ImageMetadata {
|
|
56
|
+
export interface ImageMetadata extends ImageDimensions {
|
|
40
57
|
uri: string;
|
|
41
|
-
width: number;
|
|
42
|
-
height: number;
|
|
43
58
|
format?: ImageFormat;
|
|
44
59
|
size?: number;
|
|
45
60
|
orientation?: ImageOrientation;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image
|
|
2
|
+
* Image Domain - Utility Functions
|
|
3
3
|
*/
|
|
4
|
-
import { ImageFormat, ImageOrientation,
|
|
4
|
+
import { ImageFormat, ImageOrientation, ImageDimensions, ImageCropArea } from "../entities/ImageTypes";
|
|
5
5
|
import { IMAGE_CONSTANTS } from "../entities/ImageConstants";
|
|
6
6
|
|
|
7
7
|
export class ImageUtils {
|
|
@@ -20,7 +20,7 @@ export class ImageUtils {
|
|
|
20
20
|
height: number,
|
|
21
21
|
maxWidth: number,
|
|
22
22
|
maxHeight: number
|
|
23
|
-
):
|
|
23
|
+
): ImageDimensions {
|
|
24
24
|
const aspectRatio = ImageUtils.getAspectRatio(width, height);
|
|
25
25
|
let newWidth = width;
|
|
26
26
|
let newHeight = height;
|
|
@@ -44,8 +44,8 @@ export class ImageUtils {
|
|
|
44
44
|
static getThumbnailSize(
|
|
45
45
|
width: number,
|
|
46
46
|
height: number,
|
|
47
|
-
thumbnailSize: number = IMAGE_CONSTANTS.
|
|
48
|
-
):
|
|
47
|
+
thumbnailSize: number = IMAGE_CONSTANTS.thumbnailSize
|
|
48
|
+
): ImageDimensions {
|
|
49
49
|
return ImageUtils.fitToSize(width, height, thumbnailSize, thumbnailSize);
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -77,7 +77,7 @@ export class ImageUtils {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
static getSquareCrop(width: number, height: number):
|
|
80
|
+
static getSquareCrop(width: number, height: number): ImageCropArea {
|
|
81
81
|
const size = Math.min(width, height);
|
|
82
82
|
const originX = (width - size) / 2;
|
|
83
83
|
const originY = (height - size) / 2;
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,9 @@ export type {
|
|
|
21
21
|
ImageGalleryOptions,
|
|
22
22
|
ImageOperationResult,
|
|
23
23
|
SaveFormat,
|
|
24
|
+
ImageDimensions,
|
|
25
|
+
ImageCropArea,
|
|
26
|
+
ImageFlipOptions,
|
|
24
27
|
} from './domain/entities/ImageTypes';
|
|
25
28
|
|
|
26
29
|
export {
|
|
@@ -36,6 +39,7 @@ export { ImageUtils } from './domain/utils/ImageUtils';
|
|
|
36
39
|
// =============================================================================
|
|
37
40
|
|
|
38
41
|
export { ImageTransformService } from './infrastructure/services/ImageTransformService';
|
|
42
|
+
export { ImageAdvancedTransformService } from './infrastructure/services/ImageAdvancedTransformService';
|
|
39
43
|
export { ImageConversionService } from './infrastructure/services/ImageConversionService';
|
|
40
44
|
export { ImageStorageService } from './infrastructure/services/ImageStorageService';
|
|
41
45
|
export {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Infrastructure - Advanced Transform Service
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as ImageManipulator from 'expo-image-manipulator';
|
|
6
|
+
import type {
|
|
7
|
+
ImageManipulateAction,
|
|
8
|
+
ImageSaveOptions,
|
|
9
|
+
ImageManipulationResult,
|
|
10
|
+
} from '../../domain/entities/ImageTypes';
|
|
11
|
+
import { ImageTransformService } from './ImageTransformService';
|
|
12
|
+
import { ImageUtils } from '../../domain/utils/ImageUtils';
|
|
13
|
+
import { ImageValidator } from '../utils/ImageValidator';
|
|
14
|
+
import { ImageErrorHandler, IMAGE_ERROR_CODES } from '../utils/ImageErrorHandler';
|
|
15
|
+
|
|
16
|
+
export class ImageAdvancedTransformService {
|
|
17
|
+
static async manipulate(
|
|
18
|
+
uri: string,
|
|
19
|
+
action: ImageManipulateAction,
|
|
20
|
+
options?: ImageSaveOptions
|
|
21
|
+
): Promise<ImageManipulationResult> {
|
|
22
|
+
try {
|
|
23
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
24
|
+
if (!uriValidation.isValid) {
|
|
25
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'manipulate');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const actions: ImageManipulator.Action[] = [];
|
|
29
|
+
|
|
30
|
+
if (action.resize) {
|
|
31
|
+
const dimValidation = ImageValidator.validateDimensions(action.resize);
|
|
32
|
+
if (!dimValidation.isValid) {
|
|
33
|
+
throw ImageErrorHandler.createError(dimValidation.error!, IMAGE_ERROR_CODES.INVALID_DIMENSIONS, 'manipulate');
|
|
34
|
+
}
|
|
35
|
+
actions.push({ resize: action.resize });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (action.crop) {
|
|
39
|
+
const dimValidation = ImageValidator.validateDimensions(action.crop);
|
|
40
|
+
if (!dimValidation.isValid) {
|
|
41
|
+
throw ImageErrorHandler.createError(dimValidation.error!, IMAGE_ERROR_CODES.INVALID_DIMENSIONS, 'manipulate');
|
|
42
|
+
}
|
|
43
|
+
actions.push({ crop: action.crop });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (action.rotate) {
|
|
47
|
+
const rotationValidation = ImageValidator.validateRotation(action.rotate);
|
|
48
|
+
if (!rotationValidation.isValid) {
|
|
49
|
+
throw ImageErrorHandler.createError(rotationValidation.error!, IMAGE_ERROR_CODES.VALIDATION_ERROR, 'manipulate');
|
|
50
|
+
}
|
|
51
|
+
actions.push({ rotate: action.rotate });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (action.flip) {
|
|
55
|
+
if (action.flip.horizontal) actions.push({ flip: ImageManipulator.FlipType.Horizontal });
|
|
56
|
+
if (action.flip.vertical) actions.push({ flip: ImageManipulator.FlipType.Vertical });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return await ImageManipulator.manipulateAsync(
|
|
60
|
+
uri,
|
|
61
|
+
actions,
|
|
62
|
+
ImageTransformService['buildSaveOptions'](options)
|
|
63
|
+
);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw ImageErrorHandler.handleUnknownError(error, 'manipulate');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static async resizeToFit(
|
|
70
|
+
uri: string,
|
|
71
|
+
maxWidth: number,
|
|
72
|
+
maxHeight: number,
|
|
73
|
+
options?: ImageSaveOptions
|
|
74
|
+
): Promise<ImageManipulationResult> {
|
|
75
|
+
try {
|
|
76
|
+
const dimValidation = ImageValidator.validateDimensions({ width: maxWidth, height: maxHeight });
|
|
77
|
+
if (!dimValidation.isValid) {
|
|
78
|
+
throw ImageErrorHandler.createError(dimValidation.error!, IMAGE_ERROR_CODES.INVALID_DIMENSIONS, 'resizeToFit');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dimensions = ImageUtils.fitToSize(maxWidth, maxHeight, maxWidth, maxHeight);
|
|
82
|
+
return ImageTransformService.resize(uri, dimensions.width, dimensions.height, options);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw ImageErrorHandler.handleUnknownError(error, 'resizeToFit');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
static async cropToSquare(
|
|
89
|
+
uri: string,
|
|
90
|
+
width: number,
|
|
91
|
+
height: number,
|
|
92
|
+
options?: ImageSaveOptions
|
|
93
|
+
): Promise<ImageManipulationResult> {
|
|
94
|
+
try {
|
|
95
|
+
const dimValidation = ImageValidator.validateDimensions({ width, height });
|
|
96
|
+
if (!dimValidation.isValid) {
|
|
97
|
+
throw ImageErrorHandler.createError(dimValidation.error!, IMAGE_ERROR_CODES.INVALID_DIMENSIONS, 'cropToSquare');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const cropArea = ImageUtils.getSquareCrop(width, height);
|
|
101
|
+
return ImageTransformService.crop(uri, cropArea, options);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw ImageErrorHandler.handleUnknownError(error, 'cropToSquare');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image Conversion Service
|
|
2
|
+
* Image Infrastructure - Conversion Service
|
|
3
3
|
*
|
|
4
|
-
* Handles format conversion, compression, and thumbnail generation
|
|
5
|
-
* (Thumbnail is treated as a specialized compression/resize)
|
|
4
|
+
* Handles format conversion, compression, and thumbnail generation
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import * as ImageManipulator from 'expo-image-manipulator';
|
|
@@ -13,20 +12,33 @@ import type {
|
|
|
13
12
|
} from '../../domain/entities/ImageTypes';
|
|
14
13
|
import { IMAGE_CONSTANTS } from '../../domain/entities/ImageConstants';
|
|
15
14
|
import { ImageTransformService } from './ImageTransformService';
|
|
15
|
+
import { ImageAdvancedTransformService } from './ImageAdvancedTransformService';
|
|
16
|
+
import { ImageValidator } from '../utils/ImageValidator';
|
|
17
|
+
import { ImageErrorHandler, IMAGE_ERROR_CODES } from '../utils/ImageErrorHandler';
|
|
16
18
|
|
|
17
19
|
export class ImageConversionService {
|
|
18
20
|
static async compress(
|
|
19
21
|
uri: string,
|
|
20
|
-
quality: number = IMAGE_CONSTANTS.
|
|
21
|
-
): Promise<ImageManipulationResult
|
|
22
|
+
quality: number = IMAGE_CONSTANTS.defaultQuality
|
|
23
|
+
): Promise<ImageManipulationResult> {
|
|
22
24
|
try {
|
|
25
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
26
|
+
if (!uriValidation.isValid) {
|
|
27
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'compress');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const qualityValidation = ImageValidator.validateQuality(quality);
|
|
31
|
+
if (!qualityValidation.isValid) {
|
|
32
|
+
throw ImageErrorHandler.createError(qualityValidation.error!, IMAGE_ERROR_CODES.INVALID_QUALITY, 'compress');
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
return await ImageManipulator.manipulateAsync(
|
|
24
36
|
uri,
|
|
25
37
|
[],
|
|
26
38
|
{ compress: quality }
|
|
27
39
|
);
|
|
28
|
-
} catch {
|
|
29
|
-
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw ImageErrorHandler.handleUnknownError(error, 'compress');
|
|
30
42
|
}
|
|
31
43
|
}
|
|
32
44
|
|
|
@@ -34,33 +46,54 @@ export class ImageConversionService {
|
|
|
34
46
|
uri: string,
|
|
35
47
|
format: SaveFormat,
|
|
36
48
|
quality?: number
|
|
37
|
-
): Promise<ImageManipulationResult
|
|
49
|
+
): Promise<ImageManipulationResult> {
|
|
38
50
|
try {
|
|
51
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
52
|
+
if (!uriValidation.isValid) {
|
|
53
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'convertFormat');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const compressQuality = quality ?? IMAGE_CONSTANTS.defaultQuality;
|
|
57
|
+
const qualityValidation = ImageValidator.validateQuality(compressQuality);
|
|
58
|
+
if (!qualityValidation.isValid) {
|
|
59
|
+
throw ImageErrorHandler.createError(qualityValidation.error!, IMAGE_ERROR_CODES.INVALID_QUALITY, 'convertFormat');
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
return await ImageManipulator.manipulateAsync(
|
|
40
63
|
uri,
|
|
41
64
|
[],
|
|
42
65
|
{
|
|
43
|
-
compress:
|
|
44
|
-
format: ImageTransformService
|
|
66
|
+
compress: compressQuality,
|
|
67
|
+
format: ImageTransformService['mapFormat'](format),
|
|
45
68
|
}
|
|
46
69
|
);
|
|
47
|
-
} catch {
|
|
48
|
-
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw ImageErrorHandler.handleUnknownError(error, 'convertFormat');
|
|
49
72
|
}
|
|
50
73
|
}
|
|
51
74
|
|
|
52
75
|
static async createThumbnail(
|
|
53
76
|
uri: string,
|
|
54
|
-
size: number = IMAGE_CONSTANTS.
|
|
77
|
+
size: number = IMAGE_CONSTANTS.thumbnailSize,
|
|
55
78
|
options?: ImageSaveOptions
|
|
56
|
-
): Promise<ImageManipulationResult
|
|
79
|
+
): Promise<ImageManipulationResult> {
|
|
57
80
|
try {
|
|
58
|
-
|
|
81
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
82
|
+
if (!uriValidation.isValid) {
|
|
83
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'createThumbnail');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const dimValidation = ImageValidator.validateDimensions({ width: size, height: size });
|
|
87
|
+
if (!dimValidation.isValid) {
|
|
88
|
+
throw ImageErrorHandler.createError(dimValidation.error!, IMAGE_ERROR_CODES.INVALID_DIMENSIONS, 'createThumbnail');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return await ImageAdvancedTransformService.resizeToFit(uri, size, size, {
|
|
59
92
|
...options,
|
|
60
|
-
compress: options?.compress
|
|
93
|
+
compress: options?.compress ?? IMAGE_CONSTANTS.compressQuality.medium,
|
|
61
94
|
});
|
|
62
|
-
} catch {
|
|
63
|
-
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw ImageErrorHandler.handleUnknownError(error, 'createThumbnail');
|
|
64
97
|
}
|
|
65
98
|
}
|
|
66
99
|
}
|
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image Storage Service
|
|
2
|
+
* Image Infrastructure - Storage Service
|
|
3
3
|
*
|
|
4
|
-
* Handles saving images to the device filesystem
|
|
5
|
-
* Wraps @umituz/react-native-filesystem.
|
|
4
|
+
* Handles saving images to the device filesystem
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import { FileSystemService } from '@umituz/react-native-filesystem';
|
|
8
|
+
import { ImageValidator } from '../utils/ImageValidator';
|
|
9
|
+
import { ImageErrorHandler, IMAGE_ERROR_CODES } from '../utils/ImageErrorHandler';
|
|
9
10
|
|
|
10
11
|
export class ImageStorageService {
|
|
11
12
|
static async saveImage(
|
|
12
13
|
uri: string,
|
|
13
14
|
filename?: string
|
|
14
|
-
): Promise<string
|
|
15
|
+
): Promise<string> {
|
|
15
16
|
try {
|
|
17
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
18
|
+
if (!uriValidation.isValid) {
|
|
19
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'saveImage');
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
const result = await FileSystemService.copyToDocuments(uri, filename);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
|
|
24
|
+
if (!result.success || !result.uri) {
|
|
25
|
+
throw ImageErrorHandler.createError(
|
|
26
|
+
'Failed to save image to filesystem',
|
|
27
|
+
IMAGE_ERROR_CODES.STORAGE_FAILED,
|
|
28
|
+
'saveImage'
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return result.uri;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
throw ImageErrorHandler.handleUnknownError(error, 'saveImage');
|
|
20
35
|
}
|
|
21
36
|
}
|
|
22
37
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image Transform Service
|
|
2
|
+
* Image Infrastructure - Transform Service
|
|
3
3
|
*
|
|
4
|
-
* Handles
|
|
5
|
-
* Also handles combined manipulations.
|
|
4
|
+
* Handles geometric transformations: resize, crop, rotate, flip
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import * as ImageManipulator from 'expo-image-manipulator';
|
|
@@ -11,58 +10,79 @@ import type {
|
|
|
11
10
|
ImageSaveOptions,
|
|
12
11
|
ImageManipulationResult,
|
|
13
12
|
SaveFormat,
|
|
13
|
+
ImageCropArea,
|
|
14
|
+
ImageFlipOptions,
|
|
14
15
|
} from '../../domain/entities/ImageTypes';
|
|
15
16
|
import { IMAGE_CONSTANTS } from '../../domain/entities/ImageConstants';
|
|
16
17
|
import { ImageUtils } from '../../domain/utils/ImageUtils';
|
|
18
|
+
import { ImageValidator } from '../utils/ImageValidator';
|
|
19
|
+
import { ImageErrorHandler, IMAGE_ERROR_CODES } from '../utils/ImageErrorHandler';
|
|
17
20
|
|
|
18
21
|
export class ImageTransformService {
|
|
19
|
-
|
|
20
|
-
* Helper to map SaveFormat to Manipulator format
|
|
21
|
-
*/
|
|
22
|
-
static mapFormat(format?: SaveFormat): ImageManipulator.SaveFormat {
|
|
22
|
+
private static mapFormat(format?: SaveFormat): ImageManipulator.SaveFormat {
|
|
23
23
|
if (format === 'png') return ImageManipulator.SaveFormat.PNG;
|
|
24
24
|
if (format === 'webp') return ImageManipulator.SaveFormat.WEBP;
|
|
25
25
|
return ImageManipulator.SaveFormat.JPEG;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
private static buildSaveOptions(options?: ImageSaveOptions): ImageManipulator.SaveOptions {
|
|
29
|
+
return {
|
|
30
|
+
compress: options?.compress ?? IMAGE_CONSTANTS.defaultQuality,
|
|
31
|
+
format: this.mapFormat(options?.format),
|
|
32
|
+
base64: options?.base64,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
static async resize(
|
|
29
37
|
uri: string,
|
|
30
38
|
width?: number,
|
|
31
39
|
height?: number,
|
|
32
40
|
options?: ImageSaveOptions
|
|
33
|
-
): Promise<ImageManipulationResult
|
|
41
|
+
): Promise<ImageManipulationResult> {
|
|
34
42
|
try {
|
|
43
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
44
|
+
if (!uriValidation.isValid) {
|
|
45
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'resize');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dimValidation = ImageValidator.validateDimensions({ width, height });
|
|
49
|
+
if (!dimValidation.isValid) {
|
|
50
|
+
throw ImageErrorHandler.createError(dimValidation.error!, IMAGE_ERROR_CODES.INVALID_DIMENSIONS, 'resize');
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
return await ImageManipulator.manipulateAsync(
|
|
36
54
|
uri,
|
|
37
55
|
[{ resize: { width, height } }],
|
|
38
|
-
|
|
39
|
-
compress: options?.compress || IMAGE_CONSTANTS.DEFAULT_QUALITY,
|
|
40
|
-
format: ImageTransformService.mapFormat(options?.format),
|
|
41
|
-
base64: options?.base64,
|
|
42
|
-
}
|
|
56
|
+
this.buildSaveOptions(options)
|
|
43
57
|
);
|
|
44
|
-
} catch {
|
|
45
|
-
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw ImageErrorHandler.handleUnknownError(error, 'resize');
|
|
46
60
|
}
|
|
47
61
|
}
|
|
48
62
|
|
|
49
63
|
static async crop(
|
|
50
64
|
uri: string,
|
|
51
|
-
cropArea:
|
|
65
|
+
cropArea: ImageCropArea,
|
|
52
66
|
options?: ImageSaveOptions
|
|
53
|
-
): Promise<ImageManipulationResult
|
|
67
|
+
): Promise<ImageManipulationResult> {
|
|
54
68
|
try {
|
|
69
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
70
|
+
if (!uriValidation.isValid) {
|
|
71
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'crop');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const dimValidation = ImageValidator.validateDimensions(cropArea);
|
|
75
|
+
if (!dimValidation.isValid) {
|
|
76
|
+
throw ImageErrorHandler.createError(dimValidation.error!, IMAGE_ERROR_CODES.INVALID_DIMENSIONS, 'crop');
|
|
77
|
+
}
|
|
78
|
+
|
|
55
79
|
return await ImageManipulator.manipulateAsync(
|
|
56
80
|
uri,
|
|
57
81
|
[{ crop: cropArea }],
|
|
58
|
-
|
|
59
|
-
compress: options?.compress || IMAGE_CONSTANTS.DEFAULT_QUALITY,
|
|
60
|
-
format: ImageTransformService.mapFormat(options?.format),
|
|
61
|
-
base64: options?.base64,
|
|
62
|
-
}
|
|
82
|
+
this.buildSaveOptions(options)
|
|
63
83
|
);
|
|
64
|
-
} catch {
|
|
65
|
-
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw ImageErrorHandler.handleUnknownError(error, 'crop');
|
|
66
86
|
}
|
|
67
87
|
}
|
|
68
88
|
|
|
@@ -70,28 +90,39 @@ export class ImageTransformService {
|
|
|
70
90
|
uri: string,
|
|
71
91
|
degrees: number,
|
|
72
92
|
options?: ImageSaveOptions
|
|
73
|
-
): Promise<ImageManipulationResult
|
|
93
|
+
): Promise<ImageManipulationResult> {
|
|
74
94
|
try {
|
|
95
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
96
|
+
if (!uriValidation.isValid) {
|
|
97
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'rotate');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rotationValidation = ImageValidator.validateRotation(degrees);
|
|
101
|
+
if (!rotationValidation.isValid) {
|
|
102
|
+
throw ImageErrorHandler.createError(rotationValidation.error!, IMAGE_ERROR_CODES.VALIDATION_ERROR, 'rotate');
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
return await ImageManipulator.manipulateAsync(
|
|
76
106
|
uri,
|
|
77
107
|
[{ rotate: degrees }],
|
|
78
|
-
|
|
79
|
-
compress: options?.compress || IMAGE_CONSTANTS.DEFAULT_QUALITY,
|
|
80
|
-
format: ImageTransformService.mapFormat(options?.format),
|
|
81
|
-
base64: options?.base64,
|
|
82
|
-
}
|
|
108
|
+
this.buildSaveOptions(options)
|
|
83
109
|
);
|
|
84
|
-
} catch {
|
|
85
|
-
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw ImageErrorHandler.handleUnknownError(error, 'rotate');
|
|
86
112
|
}
|
|
87
113
|
}
|
|
88
114
|
|
|
89
115
|
static async flip(
|
|
90
116
|
uri: string,
|
|
91
|
-
flip:
|
|
117
|
+
flip: ImageFlipOptions,
|
|
92
118
|
options?: ImageSaveOptions
|
|
93
|
-
): Promise<ImageManipulationResult
|
|
119
|
+
): Promise<ImageManipulationResult> {
|
|
94
120
|
try {
|
|
121
|
+
const uriValidation = ImageValidator.validateUri(uri);
|
|
122
|
+
if (!uriValidation.isValid) {
|
|
123
|
+
throw ImageErrorHandler.createError(uriValidation.error!, IMAGE_ERROR_CODES.INVALID_URI, 'flip');
|
|
124
|
+
}
|
|
125
|
+
|
|
95
126
|
const actions: ImageManipulator.Action[] = [];
|
|
96
127
|
if (flip.horizontal) actions.push({ flip: ImageManipulator.FlipType.Horizontal });
|
|
97
128
|
if (flip.vertical) actions.push({ flip: ImageManipulator.FlipType.Vertical });
|
|
@@ -99,74 +130,10 @@ export class ImageTransformService {
|
|
|
99
130
|
return await ImageManipulator.manipulateAsync(
|
|
100
131
|
uri,
|
|
101
132
|
actions,
|
|
102
|
-
|
|
103
|
-
compress: options?.compress || IMAGE_CONSTANTS.DEFAULT_QUALITY,
|
|
104
|
-
format: ImageTransformService.mapFormat(options?.format),
|
|
105
|
-
base64: options?.base64,
|
|
106
|
-
}
|
|
107
|
-
);
|
|
108
|
-
} catch {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
static async manipulate(
|
|
114
|
-
uri: string,
|
|
115
|
-
action: ImageManipulateAction,
|
|
116
|
-
options?: ImageSaveOptions
|
|
117
|
-
): Promise<ImageManipulationResult | null> {
|
|
118
|
-
try {
|
|
119
|
-
const actions: ImageManipulator.Action[] = [];
|
|
120
|
-
if (action.resize) actions.push({ resize: action.resize });
|
|
121
|
-
if (action.crop) {
|
|
122
|
-
// @ts-ignore - guarded by check above
|
|
123
|
-
actions.push({ crop: action.crop });
|
|
124
|
-
}
|
|
125
|
-
if (action.rotate) actions.push({ rotate: action.rotate });
|
|
126
|
-
if (action.flip) {
|
|
127
|
-
if (action.flip.horizontal) actions.push({ flip: ImageManipulator.FlipType.Horizontal });
|
|
128
|
-
if (action.flip.vertical) actions.push({ flip: ImageManipulator.FlipType.Vertical });
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return await ImageManipulator.manipulateAsync(
|
|
132
|
-
uri,
|
|
133
|
-
actions,
|
|
134
|
-
{
|
|
135
|
-
compress: options?.compress || IMAGE_CONSTANTS.DEFAULT_QUALITY,
|
|
136
|
-
format: ImageTransformService.mapFormat(options?.format),
|
|
137
|
-
base64: options?.base64,
|
|
138
|
-
}
|
|
133
|
+
this.buildSaveOptions(options)
|
|
139
134
|
);
|
|
140
|
-
} catch {
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
static async resizeToFit(
|
|
146
|
-
uri: string,
|
|
147
|
-
maxWidth: number,
|
|
148
|
-
maxHeight: number,
|
|
149
|
-
options?: ImageSaveOptions
|
|
150
|
-
): Promise<ImageManipulationResult | null> {
|
|
151
|
-
try {
|
|
152
|
-
const dimensions = ImageUtils.fitToSize(maxWidth, maxHeight, maxWidth, maxHeight);
|
|
153
|
-
return ImageTransformService.resize(uri, dimensions.width, dimensions.height, options);
|
|
154
|
-
} catch {
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
static async cropToSquare(
|
|
160
|
-
uri: string,
|
|
161
|
-
width: number,
|
|
162
|
-
height: number,
|
|
163
|
-
options?: ImageSaveOptions
|
|
164
|
-
): Promise<ImageManipulationResult | null> {
|
|
165
|
-
try {
|
|
166
|
-
const cropArea = ImageUtils.getSquareCrop(width, height);
|
|
167
|
-
return ImageTransformService.crop(uri, cropArea, options);
|
|
168
|
-
} catch {
|
|
169
|
-
return null;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw ImageErrorHandler.handleUnknownError(error, 'flip');
|
|
170
137
|
}
|
|
171
138
|
}
|
|
172
139
|
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image
|
|
2
|
+
* Image Infrastructure - Viewer Service
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Provides full-screen image viewer with zoom, swipe, and gallery features.
|
|
4
|
+
* Provides configuration for react-native-image-viewing component
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import type {
|
|
@@ -10,9 +9,6 @@ import type {
|
|
|
10
9
|
ImageGalleryOptions,
|
|
11
10
|
} from '../../domain/entities/ImageTypes';
|
|
12
11
|
|
|
13
|
-
/**
|
|
14
|
-
* Image viewer configuration
|
|
15
|
-
*/
|
|
16
12
|
export interface ImageViewerConfig {
|
|
17
13
|
images: ImageViewerItem[];
|
|
18
14
|
index?: number;
|
|
@@ -21,26 +17,11 @@ export interface ImageViewerConfig {
|
|
|
21
17
|
options?: ImageGalleryOptions;
|
|
22
18
|
}
|
|
23
19
|
|
|
24
|
-
/**
|
|
25
|
-
* Image viewer service
|
|
26
|
-
*
|
|
27
|
-
* NOTE: This service provides configuration for react-native-image-viewing component.
|
|
28
|
-
* The actual viewer component is rendered in the presentation layer.
|
|
29
|
-
*/
|
|
30
20
|
export class ImageViewerService {
|
|
31
|
-
/**
|
|
32
|
-
* Prepare images for viewer
|
|
33
|
-
* Converts image URIs to viewer format
|
|
34
|
-
*/
|
|
35
21
|
static prepareImages(uris: string[]): ImageViewerItem[] {
|
|
36
|
-
return uris.map(uri => ({
|
|
37
|
-
uri,
|
|
38
|
-
}));
|
|
22
|
+
return uris.map(uri => ({ uri }));
|
|
39
23
|
}
|
|
40
24
|
|
|
41
|
-
/**
|
|
42
|
-
* Prepare images with metadata
|
|
43
|
-
*/
|
|
44
25
|
static prepareImagesWithMetadata(items: ImageViewerItem[]): ImageViewerItem[] {
|
|
45
26
|
return items.map(item => ({
|
|
46
27
|
uri: item.uri,
|
|
@@ -51,9 +32,6 @@ export class ImageViewerService {
|
|
|
51
32
|
}));
|
|
52
33
|
}
|
|
53
34
|
|
|
54
|
-
/**
|
|
55
|
-
* Create viewer configuration
|
|
56
|
-
*/
|
|
57
35
|
static createViewerConfig(
|
|
58
36
|
images: ImageViewerItem[],
|
|
59
37
|
startIndex: number = 0,
|
|
@@ -74,9 +52,6 @@ export class ImageViewerService {
|
|
|
74
52
|
};
|
|
75
53
|
}
|
|
76
54
|
|
|
77
|
-
/**
|
|
78
|
-
* Get default gallery options
|
|
79
|
-
*/
|
|
80
55
|
static getDefaultOptions(): ImageGalleryOptions {
|
|
81
56
|
return {
|
|
82
57
|
index: 0,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Infrastructure - Error Handler
|
|
3
|
+
*/
|
|
4
|
+
export class ImageError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
public readonly code: string,
|
|
8
|
+
public readonly operation?: string
|
|
9
|
+
) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ImageError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const IMAGE_ERROR_CODES = {
|
|
16
|
+
INVALID_URI: 'INVALID_URI',
|
|
17
|
+
INVALID_DIMENSIONS: 'INVALID_DIMENSIONS',
|
|
18
|
+
INVALID_QUALITY: 'INVALID_QUALITY',
|
|
19
|
+
MANIPULATION_FAILED: 'MANIPULATION_FAILED',
|
|
20
|
+
CONVERSION_FAILED: 'CONVERSION_FAILED',
|
|
21
|
+
STORAGE_FAILED: 'STORAGE_FAILED',
|
|
22
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type ImageErrorCode = typeof IMAGE_ERROR_CODES[keyof typeof IMAGE_ERROR_CODES];
|
|
26
|
+
|
|
27
|
+
export class ImageErrorHandler {
|
|
28
|
+
static createError(message: string, code: ImageErrorCode, operation?: string): ImageError {
|
|
29
|
+
return new ImageError(message, code, operation);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static handleUnknownError(error: unknown, operation?: string): ImageError {
|
|
33
|
+
if (error instanceof ImageError) {
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const message = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
38
|
+
return new ImageError(message, IMAGE_ERROR_CODES.MANIPULATION_FAILED, operation);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Infrastructure - Validation Utilities
|
|
3
|
+
*/
|
|
4
|
+
import { ImageDimensions } from '../../domain/entities/ImageTypes';
|
|
5
|
+
|
|
6
|
+
export interface ValidationResult {
|
|
7
|
+
isValid: boolean;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class ImageValidator {
|
|
12
|
+
static validateUri(uri: string): ValidationResult {
|
|
13
|
+
if (!uri || typeof uri !== 'string') {
|
|
14
|
+
return { isValid: false, error: 'URI is required and must be a string' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!uri.startsWith('file://') &&
|
|
18
|
+
!uri.startsWith('content://') &&
|
|
19
|
+
!uri.startsWith('http://') &&
|
|
20
|
+
!uri.startsWith('https://') &&
|
|
21
|
+
!uri.startsWith('data:image/')) {
|
|
22
|
+
return { isValid: false, error: 'Invalid URI format' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { isValid: true };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static validateDimensions(dimensions: Partial<ImageDimensions>): ValidationResult {
|
|
29
|
+
if (dimensions.width !== undefined) {
|
|
30
|
+
if (typeof dimensions.width !== 'number' || dimensions.width <= 0) {
|
|
31
|
+
return { isValid: false, error: 'Width must be a positive number' };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (dimensions.height !== undefined) {
|
|
36
|
+
if (typeof dimensions.height !== 'number' || dimensions.height <= 0) {
|
|
37
|
+
return { isValid: false, error: 'Height must be a positive number' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { isValid: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static validateQuality(quality: number): ValidationResult {
|
|
45
|
+
if (typeof quality !== 'number' || quality < 0 || quality > 1) {
|
|
46
|
+
return { isValid: false, error: 'Quality must be a number between 0 and 1' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { isValid: true };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
static validateRotation(degrees: number): ValidationResult {
|
|
53
|
+
if (typeof degrees !== 'number') {
|
|
54
|
+
return { isValid: false, error: 'Degrees must be a number' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { isValid: true };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Header for ImageGallery with edit and close buttons
|
|
2
|
+
* Presentation - Gallery Header Component
|
|
4
3
|
*
|
|
5
|
-
* This component should be implemented by
|
|
6
|
-
* using their
|
|
4
|
+
* NOTE: This component should be implemented by consumer app
|
|
5
|
+
* using their design system and safe area handling
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import React from 'react';
|
|
10
9
|
import { View, TouchableOpacity, StyleSheet, Text } from 'react-native';
|
|
11
10
|
|
|
12
11
|
interface GalleryHeaderProps {
|
|
13
|
-
onEdit
|
|
12
|
+
onEdit?: () => void;
|
|
14
13
|
onClose: () => void;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
export function GalleryHeader({ onEdit, onClose }: GalleryHeaderProps) {
|
|
18
17
|
return (
|
|
19
18
|
<View style={styles.container}>
|
|
20
|
-
|
|
21
|
-
<
|
|
22
|
-
|
|
19
|
+
{onEdit ? (
|
|
20
|
+
<TouchableOpacity style={styles.button} onPress={onEdit}>
|
|
21
|
+
<Text style={styles.buttonText}>Edit</Text>
|
|
22
|
+
</TouchableOpacity>
|
|
23
|
+
) : (
|
|
24
|
+
<View style={styles.spacer} />
|
|
25
|
+
)}
|
|
23
26
|
|
|
24
27
|
<TouchableOpacity style={styles.button} onPress={onClose}>
|
|
25
28
|
<Text style={styles.buttonText}>✕</Text>
|
|
@@ -42,6 +45,9 @@ const styles = StyleSheet.create({
|
|
|
42
45
|
alignItems: 'center',
|
|
43
46
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
44
47
|
},
|
|
48
|
+
spacer: {
|
|
49
|
+
width: 48,
|
|
50
|
+
},
|
|
45
51
|
button: {
|
|
46
52
|
flexDirection: 'row',
|
|
47
53
|
alignItems: 'center',
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image Gallery Component
|
|
2
|
+
* Presentation - Image Gallery Component
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* theme integration, standard configuration, and optional editing.
|
|
4
|
+
* Wrapper around react-native-image-viewing with theme integration
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import React, { useCallback } from 'react';
|
|
9
8
|
import ImageViewing from 'react-native-image-viewing';
|
|
10
|
-
import
|
|
11
|
-
import { useAppDesignTokens } from '@umituz/react-native-design-system-theme';
|
|
9
|
+
// import { useAppDesignTokens } from '@umituz/react-native-design-system-theme';
|
|
12
10
|
import type { ImageViewerItem, ImageGalleryOptions } from '../../domain/entities/ImageTypes';
|
|
13
11
|
import { GalleryHeader } from './GalleryHeader';
|
|
14
12
|
|
|
@@ -33,14 +31,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|
|
33
31
|
onImageChange,
|
|
34
32
|
enableEditing = false,
|
|
35
33
|
}) => {
|
|
36
|
-
const tokens = useAppDesignTokens();
|
|
34
|
+
// const tokens = useAppDesignTokens();
|
|
37
35
|
const [currentIndex, setCurrentIndex] = React.useState(index);
|
|
38
36
|
|
|
39
37
|
React.useEffect(() => {
|
|
40
38
|
setCurrentIndex(index);
|
|
41
39
|
}, [index]);
|
|
42
40
|
|
|
43
|
-
const bg = backgroundColor ||
|
|
41
|
+
const bg = backgroundColor || '#000000';
|
|
44
42
|
|
|
45
43
|
const viewerImages = React.useMemo(
|
|
46
44
|
() => images.map((img) => ({ uri: img.uri })),
|
|
@@ -52,20 +50,9 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|
|
52
50
|
if (!currentImage || !onImageChange) return;
|
|
53
51
|
|
|
54
52
|
try {
|
|
55
|
-
|
|
56
|
-
currentImage.uri,
|
|
57
|
-
[],
|
|
58
|
-
{
|
|
59
|
-
compress: 1,
|
|
60
|
-
format: ImageManipulator.SaveFormat.JPEG,
|
|
61
|
-
}
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
if (result.uri) {
|
|
65
|
-
await onImageChange(result.uri, currentIndex);
|
|
66
|
-
}
|
|
53
|
+
await onImageChange(currentImage.uri, currentIndex);
|
|
67
54
|
} catch (error) {
|
|
68
|
-
//
|
|
55
|
+
// Consumer should handle editing logic
|
|
69
56
|
}
|
|
70
57
|
}, [images, currentIndex, onImageChange]);
|
|
71
58
|
|
|
@@ -78,10 +65,9 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|
|
78
65
|
);
|
|
79
66
|
|
|
80
67
|
const headerComponent = useCallback(() => {
|
|
81
|
-
if (!enableEditing) return null;
|
|
82
68
|
return (
|
|
83
69
|
<GalleryHeader
|
|
84
|
-
onEdit={handleEdit}
|
|
70
|
+
onEdit={enableEditing ? handleEdit : undefined}
|
|
85
71
|
onClose={onDismiss}
|
|
86
72
|
/>
|
|
87
73
|
);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Presentation - Image Hook
|
|
3
3
|
*
|
|
4
|
-
* Aggregator hook
|
|
5
|
-
* Kept simple and modular.
|
|
4
|
+
* Aggregator hook combining transformation and conversion capabilities
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import { useImageTransform } from './useImageTransform';
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation - Image Conversion Hook
|
|
3
|
+
*/
|
|
1
4
|
import { useCallback } from 'react';
|
|
2
5
|
import { useImageOperation } from './useImageOperation';
|
|
3
6
|
import { ImageConversionService } from '../../infrastructure/services/ImageConversionService';
|
|
@@ -7,17 +10,17 @@ import type { ImageSaveOptions, SaveFormat } from '../../domain/entities/ImageTy
|
|
|
7
10
|
export const useImageConversion = () => {
|
|
8
11
|
const { isProcessing, error, execute } = useImageOperation();
|
|
9
12
|
|
|
10
|
-
const compress = useCallback((uri: string, quality
|
|
13
|
+
const compress = useCallback((uri: string, quality?: number) =>
|
|
11
14
|
execute(() => ImageConversionService.compress(uri, quality), 'Failed to compress'), [execute]);
|
|
12
15
|
|
|
13
16
|
const convertFormat = useCallback((uri: string, format: SaveFormat, quality?: number) =>
|
|
14
|
-
execute(() => ImageConversionService.convertFormat(uri, format, quality), 'Failed to convert'), [execute]);
|
|
17
|
+
execute(() => ImageConversionService.convertFormat(uri, format, quality), 'Failed to convert format'), [execute]);
|
|
15
18
|
|
|
16
19
|
const createThumbnail = useCallback((uri: string, size?: number, options?: ImageSaveOptions) =>
|
|
17
20
|
execute(() => ImageConversionService.createThumbnail(uri, size, options), 'Failed to create thumbnail'), [execute]);
|
|
18
21
|
|
|
19
22
|
const saveImage = useCallback((uri: string, filename?: string) =>
|
|
20
|
-
execute(() => ImageStorageService.saveImage(uri, filename), 'Failed to save'), [execute]);
|
|
23
|
+
execute(() => ImageStorageService.saveImage(uri, filename), 'Failed to save image'), [execute]);
|
|
21
24
|
|
|
22
25
|
return {
|
|
23
26
|
compress, convertFormat, createThumbnail, saveImage,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Presentation - Image Editor Hook
|
|
3
|
+
*
|
|
4
|
+
* NOTE: This hook is deprecated - use useImageTransform instead
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { useState, useCallback } from 'react';
|
|
7
|
-
import * as ImageManipulator from 'expo-image-manipulator';
|
|
8
8
|
import type { Action } from 'expo-image-manipulator';
|
|
9
9
|
|
|
10
10
|
interface UseImageEditorOptions {
|
|
@@ -30,20 +30,14 @@ export function useImageEditor({ onSave }: UseImageEditorOptions = {}) {
|
|
|
30
30
|
if (!currentUri) return;
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
|
-
const result = await ImageManipulator.manipulateAsync(
|
|
34
|
-
currentUri,
|
|
35
|
-
actions,
|
|
36
|
-
{ compress: 0.9, format: ImageManipulator.SaveFormat.JPEG }
|
|
37
|
-
);
|
|
38
|
-
|
|
39
33
|
if (onSave) {
|
|
40
|
-
await onSave(
|
|
34
|
+
await onSave(currentUri);
|
|
41
35
|
}
|
|
42
36
|
|
|
43
37
|
setIsEditing(false);
|
|
44
38
|
setCurrentUri(null);
|
|
45
39
|
|
|
46
|
-
return
|
|
40
|
+
return currentUri;
|
|
47
41
|
} catch (error) {
|
|
48
42
|
throw error;
|
|
49
43
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* React hook for image gallery and viewer using react-native-image-viewing.
|
|
5
|
-
* Provides full-screen image viewer with zoom, swipe, and gallery features.
|
|
2
|
+
* Presentation - Image Gallery Hook
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
import { useState, useCallback, useMemo } from 'react';
|
|
@@ -12,51 +9,16 @@ import type {
|
|
|
12
9
|
ImageGalleryOptions,
|
|
13
10
|
} from '../../domain/entities/ImageTypes';
|
|
14
11
|
|
|
15
|
-
/**
|
|
16
|
-
* useImageGallery hook return type
|
|
17
|
-
*/
|
|
18
12
|
export interface UseImageGalleryReturn {
|
|
19
|
-
// State
|
|
20
13
|
visible: boolean;
|
|
21
14
|
currentIndex: number;
|
|
22
15
|
images: ImageViewerItem[];
|
|
23
|
-
|
|
24
|
-
// Actions
|
|
25
16
|
open: (images: ImageViewerItem[] | string[], startIndex?: number, options?: ImageGalleryOptions) => void;
|
|
26
17
|
close: () => void;
|
|
27
18
|
setIndex: (index: number) => void;
|
|
28
|
-
|
|
29
|
-
// Gallery options
|
|
30
19
|
options: ImageGalleryOptions;
|
|
31
20
|
}
|
|
32
21
|
|
|
33
|
-
/**
|
|
34
|
-
* useImageGallery hook for full-screen image viewer
|
|
35
|
-
*
|
|
36
|
-
* USAGE:
|
|
37
|
-
* ```typescript
|
|
38
|
-
* const { visible, currentIndex, images, open, close, options } = useImageGallery();
|
|
39
|
-
*
|
|
40
|
-
* // Open gallery with image URIs
|
|
41
|
-
* open(['uri1', 'uri2', 'uri3']);
|
|
42
|
-
*
|
|
43
|
-
* // Open gallery with metadata
|
|
44
|
-
* open([
|
|
45
|
-
* { uri: 'uri1', title: 'Photo 1' },
|
|
46
|
-
* { uri: 'uri2', title: 'Photo 2' },
|
|
47
|
-
* ], 0, { backgroundColor: '#000000' });
|
|
48
|
-
*
|
|
49
|
-
* // Render ImageViewing component
|
|
50
|
-
* <ImageViewing
|
|
51
|
-
* images={images}
|
|
52
|
-
* imageIndex={currentIndex}
|
|
53
|
-
* visible={visible}
|
|
54
|
-
* onRequestClose={close}
|
|
55
|
-
* onIndexChange={setIndex}
|
|
56
|
-
* {...options}
|
|
57
|
-
* />
|
|
58
|
-
* ```
|
|
59
|
-
*/
|
|
60
22
|
export const useImageGallery = (
|
|
61
23
|
defaultOptions?: ImageGalleryOptions
|
|
62
24
|
): UseImageGalleryReturn => {
|
|
@@ -67,16 +29,12 @@ export const useImageGallery = (
|
|
|
67
29
|
defaultOptions || ImageViewerService.getDefaultOptions()
|
|
68
30
|
);
|
|
69
31
|
|
|
70
|
-
/**
|
|
71
|
-
* Open gallery with images
|
|
72
|
-
*/
|
|
73
32
|
const open = useCallback(
|
|
74
33
|
(
|
|
75
34
|
imageData: ImageViewerItem[] | string[],
|
|
76
35
|
startIndex: number = 0,
|
|
77
36
|
options?: ImageGalleryOptions
|
|
78
37
|
) => {
|
|
79
|
-
// Prepare images based on input type
|
|
80
38
|
const preparedImages =
|
|
81
39
|
typeof imageData[0] === 'string'
|
|
82
40
|
? ImageViewerService.prepareImages(imageData as string[])
|
|
@@ -85,7 +43,6 @@ export const useImageGallery = (
|
|
|
85
43
|
setImages(preparedImages);
|
|
86
44
|
setCurrentIndex(options?.index ?? startIndex);
|
|
87
45
|
|
|
88
|
-
// Merge options with defaults
|
|
89
46
|
if (options) {
|
|
90
47
|
setGalleryOptions({
|
|
91
48
|
...galleryOptions,
|
|
@@ -98,33 +55,22 @@ export const useImageGallery = (
|
|
|
98
55
|
[galleryOptions]
|
|
99
56
|
);
|
|
100
57
|
|
|
101
|
-
/**
|
|
102
|
-
* Close gallery
|
|
103
|
-
*/
|
|
104
58
|
const close = useCallback(() => {
|
|
105
59
|
setVisible(false);
|
|
106
60
|
|
|
107
|
-
// Call onDismiss if provided
|
|
108
61
|
if (galleryOptions.onDismiss) {
|
|
109
62
|
galleryOptions.onDismiss();
|
|
110
63
|
}
|
|
111
64
|
}, [galleryOptions]);
|
|
112
65
|
|
|
113
|
-
/**
|
|
114
|
-
* Set current image index
|
|
115
|
-
*/
|
|
116
66
|
const setIndex = useCallback((index: number) => {
|
|
117
67
|
setCurrentIndex(index);
|
|
118
68
|
|
|
119
|
-
// Call onIndexChange if provided
|
|
120
69
|
if (galleryOptions.onIndexChange) {
|
|
121
70
|
galleryOptions.onIndexChange(index);
|
|
122
71
|
}
|
|
123
72
|
}, [galleryOptions]);
|
|
124
73
|
|
|
125
|
-
/**
|
|
126
|
-
* Memoized options for ImageViewing component
|
|
127
|
-
*/
|
|
128
74
|
const options = useMemo(() => ({
|
|
129
75
|
backgroundColor: galleryOptions.backgroundColor || '#000000',
|
|
130
76
|
swipeToCloseEnabled: galleryOptions.swipeToCloseEnabled ?? true,
|
|
@@ -132,17 +78,12 @@ export const useImageGallery = (
|
|
|
132
78
|
}), [galleryOptions]);
|
|
133
79
|
|
|
134
80
|
return {
|
|
135
|
-
// State
|
|
136
81
|
visible,
|
|
137
82
|
currentIndex,
|
|
138
83
|
images,
|
|
139
|
-
|
|
140
|
-
// Actions
|
|
141
84
|
open,
|
|
142
85
|
close,
|
|
143
86
|
setIndex,
|
|
144
|
-
|
|
145
|
-
// Gallery options
|
|
146
87
|
options,
|
|
147
88
|
};
|
|
148
89
|
};
|
|
@@ -1,28 +1,32 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Presentation - Image Operation Hook
|
|
3
|
+
*
|
|
4
|
+
* Generic state management for async image operations
|
|
6
5
|
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback } from 'react';
|
|
8
|
+
import { ImageError } from '../../infrastructure/utils/ImageErrorHandler';
|
|
9
|
+
|
|
7
10
|
export const useImageOperation = () => {
|
|
8
11
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
9
12
|
const [error, setError] = useState<string | null>(null);
|
|
10
13
|
|
|
11
14
|
const execute = useCallback(async <T>(
|
|
12
|
-
operation: () => Promise<T
|
|
15
|
+
operation: () => Promise<T>,
|
|
13
16
|
errorMessage: string
|
|
14
17
|
): Promise<T | null> => {
|
|
15
18
|
setIsProcessing(true);
|
|
16
19
|
setError(null);
|
|
20
|
+
|
|
17
21
|
try {
|
|
18
22
|
const result = await operation();
|
|
19
|
-
if (!result) {
|
|
20
|
-
setError(errorMessage);
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
23
|
return result;
|
|
24
24
|
} catch (err) {
|
|
25
|
-
|
|
25
|
+
if (err instanceof ImageError) {
|
|
26
|
+
setError(err.message);
|
|
27
|
+
} else {
|
|
28
|
+
setError(err instanceof Error ? err.message : errorMessage);
|
|
29
|
+
}
|
|
26
30
|
return null;
|
|
27
31
|
} finally {
|
|
28
32
|
setIsProcessing(false);
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation - Image Transform Hook
|
|
3
|
+
*/
|
|
1
4
|
import { useCallback } from 'react';
|
|
2
5
|
import { useImageOperation } from './useImageOperation';
|
|
3
6
|
import { ImageTransformService } from '../../infrastructure/services/ImageTransformService';
|
|
7
|
+
import { ImageAdvancedTransformService } from '../../infrastructure/services/ImageAdvancedTransformService';
|
|
4
8
|
import type {
|
|
5
9
|
ImageManipulateAction,
|
|
6
10
|
ImageSaveOptions,
|
|
11
|
+
ImageCropArea,
|
|
12
|
+
ImageFlipOptions,
|
|
7
13
|
} from '../../domain/entities/ImageTypes';
|
|
8
14
|
|
|
9
15
|
export const useImageTransform = () => {
|
|
@@ -12,23 +18,23 @@ export const useImageTransform = () => {
|
|
|
12
18
|
const resize = useCallback((uri: string, width?: number, height?: number, options?: ImageSaveOptions) =>
|
|
13
19
|
execute(() => ImageTransformService.resize(uri, width, height, options), 'Failed to resize'), [execute]);
|
|
14
20
|
|
|
15
|
-
const crop = useCallback((uri: string, cropArea:
|
|
21
|
+
const crop = useCallback((uri: string, cropArea: ImageCropArea, options?: ImageSaveOptions) =>
|
|
16
22
|
execute(() => ImageTransformService.crop(uri, cropArea, options), 'Failed to crop'), [execute]);
|
|
17
23
|
|
|
18
24
|
const rotate = useCallback((uri: string, degrees: number, options?: ImageSaveOptions) =>
|
|
19
25
|
execute(() => ImageTransformService.rotate(uri, degrees, options), 'Failed to rotate'), [execute]);
|
|
20
26
|
|
|
21
|
-
const flip = useCallback((uri: string, flipParams:
|
|
27
|
+
const flip = useCallback((uri: string, flipParams: ImageFlipOptions, options?: ImageSaveOptions) =>
|
|
22
28
|
execute(() => ImageTransformService.flip(uri, flipParams, options), 'Failed to flip'), [execute]);
|
|
23
29
|
|
|
24
30
|
const manipulate = useCallback((uri: string, action: ImageManipulateAction, options?: ImageSaveOptions) =>
|
|
25
|
-
execute(() =>
|
|
31
|
+
execute(() => ImageAdvancedTransformService.manipulate(uri, action, options), 'Failed to manipulate'), [execute]);
|
|
26
32
|
|
|
27
|
-
const resizeToFit = useCallback((uri: string,
|
|
28
|
-
execute(() =>
|
|
33
|
+
const resizeToFit = useCallback((uri: string, maxWidth: number, maxHeight: number, options?: ImageSaveOptions) =>
|
|
34
|
+
execute(() => ImageAdvancedTransformService.resizeToFit(uri, maxWidth, maxHeight, options), 'Failed to resize to fit'), [execute]);
|
|
29
35
|
|
|
30
|
-
const cropToSquare = useCallback((uri: string,
|
|
31
|
-
execute(() =>
|
|
36
|
+
const cropToSquare = useCallback((uri: string, width: number, height: number, options?: ImageSaveOptions) =>
|
|
37
|
+
execute(() => ImageAdvancedTransformService.cropToSquare(uri, width, height, options), 'Failed to crop square'), [execute]);
|
|
32
38
|
|
|
33
39
|
return {
|
|
34
40
|
resize, crop, rotate, flip, manipulate, resizeToFit, cropToSquare,
|