Skip to content

Commit

Permalink
Merge pull request #146 from callstack/feature/retyui/headers
Browse files Browse the repository at this point in the history
Allow pass `headers`
  • Loading branch information
retyui authored Mar 1, 2024
2 parents bc7a5fb + 9ccc5c1 commit e0e153c
Show file tree
Hide file tree
Showing 7 changed files with 134 additions and 61 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ ImageEditor.cropImage(uri, cropData).then((result) => {
| `quality`<br>_(optional)_ | `number` | A value in range `0.0` - `1.0` specifying compression level of the result image. `1` means no compression (highest quality) and `0` the highest compression (lowest quality) <br/>**Default value**: `0.9` |
| `format`<br>_(optional)_ | `'jpeg' \| 'png' \| 'webp'` | The format of the resulting image.<br/> **Default value**: based on the provided image;<br>if value determination is not possible, `'jpeg'` will be used as a fallback.<br/>`'webp'` isn't supported by iOS. |
| `includeBase64`<br>_(optional)_ | `boolean` | Indicates if Base64 formatted picture data should also be included in the [`CropResult`](#result-cropresult). <br/>**Default value**: `false` |
| `headers`<br>_(optional)_ | `object \| Headers` | An object or [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) interface representing the HTTP headers to send along with the request for a remote image. |

### `result: CropResult`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.facebook.react.bridge.JSApplicationIllegalArgumentException
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
import com.facebook.react.bridge.WritableMap
import com.facebook.react.common.ReactConstants
import java.io.ByteArrayInputStream
Expand Down Expand Up @@ -99,6 +100,10 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
* is passed to this is the file:// URI of the new image
*/
fun cropImage(uri: String?, options: ReadableMap, promise: Promise) {
val headers =
if (options.hasKey("headers") && options.getType("headers") == ReadableType.Map)
options.getMap("headers")?.toHashMap()
else null
val format = if (options.hasKey("format")) options.getString("format") else null
val offset = if (options.hasKey("offset")) options.getMap("offset") else null
val size = if (options.hasKey("size")) options.getMap("size") else null
Expand Down Expand Up @@ -152,10 +157,11 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
width,
height,
targetWidth,
targetHeight
targetHeight,
headers
)
} else {
cropTask(outOptions, uri, x, y, width, height)
cropTask(outOptions, uri, x, y, width, height, headers)
}
if (cropped == null) {
throw IOException("Cannot decode bitmap: $uri")
Expand Down Expand Up @@ -189,9 +195,10 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
x: Int,
y: Int,
width: Int,
height: Int
height: Int,
headers: HashMap<String, Any?>?
): Bitmap? {
return openBitmapInputStream(uri)?.use {
return openBitmapInputStream(uri, headers)?.use {
// Efficiently crops image without loading full resolution into memory
// https://developer.android.com/reference/android/graphics/BitmapRegionDecoder.html
val decoder =
Expand Down Expand Up @@ -251,6 +258,7 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
rectHeight: Int,
outputWidth: Int,
outputHeight: Int,
headers: HashMap<String, Any?>?
): Bitmap? {
Assertions.assertNotNull(outOptions)

Expand All @@ -262,7 +270,7 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
// Where would the crop rect end up within the scaled bitmap?

val bitmap =
openBitmapInputStream(uri)?.use {
openBitmapInputStream(uri, headers)?.use {
// This can use significantly less memory than decoding the full-resolution bitmap
BitmapFactory.decodeStream(it, null, outOptions)
} ?: return null
Expand Down Expand Up @@ -318,14 +326,19 @@ class ImageEditorModuleImpl(private val reactContext: ReactApplicationContext) {
return Bitmap.createBitmap(bitmap, cropX, cropY, cropWidth, cropHeight, scaleMatrix, filter)
}

private fun openBitmapInputStream(uri: String): InputStream? {
private fun openBitmapInputStream(uri: String, headers: HashMap<String, Any?>?): InputStream? {
return if (uri.startsWith("data:")) {
val src = uri.substring(uri.indexOf(",") + 1)
ByteArrayInputStream(Base64.decode(src, Base64.DEFAULT))
} else if (isLocalUri(uri)) {
reactContext.contentResolver.openInputStream(Uri.parse(uri))
} else {
val connection = URL(uri).openConnection()
headers?.forEach { (key, value) ->
if (value is String) {
connection.setRequestProperty(key, value)
}
}
connection.getInputStream()
}
}
Expand Down
20 changes: 15 additions & 5 deletions ios/RNCImageEditor.mm
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
CGFloat quality;
NSString *format;
BOOL includeBase64;
NSDictionary *headers;
};

@implementation RNCImageEditor
Expand All @@ -54,6 +55,7 @@ - (Params)adaptParamsWithFormat:(id)format
displayHeight:(id)displayHeight
quality:(id)quality
includeBase64:(id)includeBase64
headers:(id)headers
{
return Params{
.offset = {[RCTConvert double:offsetX], [RCTConvert double:offsetY]},
Expand All @@ -62,7 +64,8 @@ - (Params)adaptParamsWithFormat:(id)format
.resizeMode = [RCTConvert RCTResizeMode:resizeMode ?: @(DEFAULT_RESIZE_MODE)],
.quality = [RCTConvert CGFloat:quality],
.format = [RCTConvert NSString:format],
.includeBase64 = [RCTConvert BOOL:includeBase64]
.includeBase64 = [RCTConvert BOOL:includeBase64],
.headers = [RCTConvert NSDictionary:RCTNilIfNull(headers)]
};
}

Expand All @@ -83,7 +86,6 @@ - (void) cropImage:(NSString *)uri
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
NSURLRequest *imageRequest = [NSURLRequest requestWithURL:[NSURL URLWithString: uri]];
auto params = [self adaptParamsWithFormat:data.format()
width:@(data.size().width())
height:@(data.size().height())
Expand All @@ -93,9 +95,10 @@ - (void) cropImage:(NSString *)uri
displayWidth:@(data.displaySize().has_value() ? data.displaySize()->width() : DEFAULT_DISPLAY_SIZE)
displayHeight:@(data.displaySize().has_value() ? data.displaySize()->height() : DEFAULT_DISPLAY_SIZE)
quality:@(data.quality().has_value() ? *data.quality() : DEFAULT_COMPRESSION_QUALITY)
includeBase64:@(data.includeBase64().has_value() ? *data.includeBase64() : NO)];
includeBase64:@(data.includeBase64().has_value() ? *data.includeBase64() : NO)
headers: data.headers()];
#else
RCT_EXPORT_METHOD(cropImage:(NSURLRequest *)imageRequest
RCT_EXPORT_METHOD(cropImage:(NSString *)uri
cropData:(NSDictionary *)cropData
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject)
Expand All @@ -110,9 +113,16 @@ - (void) cropImage:(NSString *)uri
displayHeight:cropData[@"displaySize"] ? cropData[@"displaySize"][@"height"] : @(DEFAULT_DISPLAY_SIZE)
quality:cropData[@"quality"] ? cropData[@"quality"] : @(DEFAULT_COMPRESSION_QUALITY)
includeBase64:cropData[@"includeBase64"]
];
headers:cropData[@"headers"]];

#endif
NSMutableURLRequest *imageRequest = [NSMutableURLRequest requestWithURL:[NSURL URLWithString: uri]];
[params.headers enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) {
if (value) {
[imageRequest addValue:[RCTConvert NSString:value] forHTTPHeaderField:key];
}
}];

NSURL *url = [imageRequest URL];
NSString *urlPath = [url path];
NSString *extension = [urlPath pathExtension];
Expand Down
7 changes: 7 additions & 0 deletions src/NativeRNCImageEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export interface Spec extends TurboModule {
* (Optional) Indicates if Base64 formatted picture data should also be included in the result.
*/
includeBase64?: boolean;

/**
* (Optional) An object representing the HTTP headers to send along with the request for a remote image.
*/
headers?: {
[key: string]: string;
};
}
): Promise<{
/**
Expand Down
16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ const RNCImageEditor: Spec = NativeRNCImageEditor
type CropResultWithoutBase64 = Omit<CropResult, 'base64'>;
type ImageCropDataWithoutBase64 = Omit<ImageCropData, 'includeBase64'>;

function toHeadersObject(
headers: ImageCropData['headers']
): Record<string, string> | undefined {
return headers instanceof Headers
? Object.fromEntries(
// @ts-expect-error: Headers.entries isn't added yet in TS but exists in Runtime
headers.entries()
)
: headers;
}

class ImageEditor {
/**
* Crop the image specified by the URI param. If URI points to a remote
Expand Down Expand Up @@ -56,7 +67,10 @@ class ImageEditor {
): Promise<CropResultWithoutBase64 & { base64: string }>;

static cropImage(uri: string, cropData: ImageCropData): Promise<CropResult> {
return RNCImageEditor.cropImage(uri, cropData) as Promise<CropResult>;
return RNCImageEditor.cropImage(uri, {
...cropData,
headers: toHeadersObject(cropData.headers),
}) as Promise<CropResult>;
}
}

Expand Down
123 changes: 75 additions & 48 deletions src/index.web.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { ImageCropData, CropResult } from './types.ts';

const ERROR_PREFIX = 'ImageEditor: ';

function drawImage(
img: HTMLImageElement,
img: HTMLImageElement | ImageBitmap,
{ offset, size, displaySize }: ImageCropData
): HTMLCanvasElement {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');

if (!context) {
throw new Error('Failed to get canvas context');
throw new Error(ERROR_PREFIX + 'Failed to get canvas context');
}

const sx = offset.x,
Expand All @@ -28,7 +30,30 @@ function drawImage(
return canvas;
}

function fetchImage(imgSrc: string): Promise<HTMLImageElement> {
function fetchImage(
imgSrc: string,
headers: ImageCropData['headers']
): Promise<HTMLImageElement | ImageBitmap> {
if (headers) {
return fetch(imgSrc, {
method: 'GET',
headers: new Headers(headers),
})
.then((response) => {

Check warning on line 42 in src/index.web.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Prefer await to then()/catch()/finally()
if (!response.ok) {
throw new Error(
ERROR_PREFIX +
'Failed to fetch the image: ' +
imgSrc +
'. Request failed with status: ' +
response.status
);
}
return response.blob();
})
.then((blob) => createImageBitmap(blob));

Check warning on line 54 in src/index.web.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Prefer await to then()/catch()/finally()
}

return new Promise<HTMLImageElement>((resolve, reject) => {
const onceOptions = { once: true };
const img = new Image();
Expand Down Expand Up @@ -58,51 +83,53 @@ class ImageEditor {
/**
* Returns a promise that resolves with the base64 encoded string of the cropped image
*/
return fetchImage(imgSrc).then(function onfulfilledImgToCanvas(image) {
const ext = cropData.format ?? 'jpeg';
const type = `image/${ext}`;
const quality = cropData.quality ?? DEFAULT_COMPRESSION_QUALITY;
const canvas = drawImage(image, cropData);

return new Promise<Blob | null>(function onfulfilledCanvasToBlob(
resolve
) {
canvas.toBlob(resolve, type, quality);
}).then((blob) => {
if (!blob) {
throw new Error('Image cannot be created from canvas');
}

let _path: string, _uri: string;

const result: CropResult = {
width: canvas.width,
height: canvas.height,
name: 'ReactNative_cropped_image.' + ext,
type: ('image/' + ext) as CropResult['type'],
size: blob.size,
// Lazy getters to avoid unnecessary memory usage
get path() {
if (!_path) {
_path = URL.createObjectURL(blob);
}
return _path;
},
get uri() {
return result.base64 as string;
},
get base64() {
if (!_uri) {
_uri = canvas.toDataURL(type, quality);
}
return _uri.split(',')[1];
// ^^^ remove `data:image/xxx;base64,` prefix (to align with iOS/Android platform behavior)
},
};

return result;
});
});
return fetchImage(imgSrc, cropData.headers).then(

Check warning on line 86 in src/index.web.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Prefer await to then()/catch()/finally()
function onfulfilledImgToCanvas(image) {
const ext = cropData.format ?? 'jpeg';
const type = `image/${ext}`;
const quality = cropData.quality ?? DEFAULT_COMPRESSION_QUALITY;
const canvas = drawImage(image, cropData);

return new Promise<Blob | null>(function onfulfilledCanvasToBlob(
resolve
) {
canvas.toBlob(resolve, type, quality);
}).then((blob) => {

Check warning on line 97 in src/index.web.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Prefer await to then()/catch()/finally()
if (!blob) {
throw new Error('Image cannot be created from canvas');
}

let _path: string, _uri: string;

const result: CropResult = {
width: canvas.width,
height: canvas.height,
name: 'ReactNative_cropped_image.' + ext,
type: ('image/' + ext) as CropResult['type'],
size: blob.size,
// Lazy getters to avoid unnecessary memory usage
get path() {
if (!_path) {
_path = URL.createObjectURL(blob);
}
return _path;
},
get uri() {
return result.base64 as string;
},
get base64() {
if (!_uri) {
_uri = canvas.toDataURL(type, quality);
}
return _uri.split(',')[1];
// ^^^ remove `data:image/xxx;base64,` prefix (to align with iOS/Android platform behavior)
},
};

return result;
});
}
);
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { Spec } from './NativeRNCImageEditor.ts';
type ImageCropDataFromSpec = Parameters<Spec['cropImage']>[1];

export interface ImageCropData
extends Omit<ImageCropDataFromSpec, 'resizeMode' | 'format'> {
extends Omit<ImageCropDataFromSpec, 'headers' | 'resizeMode' | 'format'> {
headers?: Record<string, string> | Headers;
format?: 'png' | 'jpeg' | 'webp';
resizeMode?: 'contain' | 'cover' | 'stretch' | 'center';
// ^^^ codegen doesn't support union types yet
Expand Down

0 comments on commit e0e153c

Please sign in to comment.