Split image cropping into secondary step (#473)
* Split image cropping into secondary step * Use ImageModel and GalleryModel * Add fix for pasting image URLs * Move models to state folder * Fix things that broke after rebase * Latest -- has image display bug * Remove contentFit * Fix iOS display in gallery * Tuneup the api signatures and implement compress/resize on web * Fix await * Lint fix and remove unused function * Fix android image pathing * Fix external embed x button on android * Remove min-height from composer (no longer useful and was mispositioning the composer on android) * Fix e2e picker --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
parent
91fadadb58
commit
2509290fdd
30 changed files with 875 additions and 833 deletions
|
@ -10,8 +10,8 @@ import {
|
|||
import {AtUri} from '@atproto/api'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {Image} from 'lib/media/types'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {Image} from '../media/manip'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
|
|
|
@ -161,6 +161,8 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export const POST_IMG_MAX_WIDTH = 2000
|
||||
export const POST_IMG_MAX_HEIGHT = 2000
|
||||
export const POST_IMG_MAX_SIZE = 1000000
|
||||
export const POST_IMG_MAX = {
|
||||
width: 2000,
|
||||
height: 2000,
|
||||
size: 1000000,
|
||||
}
|
||||
|
|
|
@ -1,13 +1,77 @@
|
|||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import {Image as RNImage, Share} from 'react-native'
|
||||
import {Image} from 'react-native-image-crop-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
import uuid from 'react-native-uuid'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {Dimensions} from './types'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
|
||||
export interface Dim {
|
||||
width: number
|
||||
height: number
|
||||
export async function compressAndResizeImageForPost(
|
||||
image: Image,
|
||||
): Promise<Image> {
|
||||
const uri = `file://${image.path}`
|
||||
let resized: Omit<Image, 'mime'>
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const quality = 100 - i * 10
|
||||
|
||||
try {
|
||||
resized = await ImageResizer.createResizedImage(
|
||||
uri,
|
||||
POST_IMG_MAX.width,
|
||||
POST_IMG_MAX.height,
|
||||
'JPEG',
|
||||
quality,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{mode: 'cover'},
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to resize: ${err}`)
|
||||
}
|
||||
|
||||
if (resized.size < POST_IMG_MAX.size) {
|
||||
const path = await moveToPermanentPath(resized.path)
|
||||
|
||||
return {
|
||||
path,
|
||||
mime: 'image/jpeg',
|
||||
size: resized.size,
|
||||
height: resized.height,
|
||||
width: resized.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: Image,
|
||||
maxSize: number = 1000000,
|
||||
): Promise<Image> {
|
||||
const origUri = `file://${img.path}`
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
}
|
||||
const resizedImage = await doResize(origUri, {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
mode: 'stretch',
|
||||
maxSize,
|
||||
})
|
||||
const finalImageMovedPath = await moveToPermanentPath(resizedImage.path)
|
||||
const finalImg = {
|
||||
...resizedImage,
|
||||
path: finalImageMovedPath,
|
||||
}
|
||||
return finalImg
|
||||
}
|
||||
|
||||
export interface DownloadAndResizeOpts {
|
||||
|
@ -19,14 +83,6 @@ export interface DownloadAndResizeOpts {
|
|||
timeout: number
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
path: string
|
||||
mime: string
|
||||
size: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
let appendExt = 'jpeg'
|
||||
try {
|
||||
|
@ -55,7 +111,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
|||
localUri = `file://${localUri}`
|
||||
}
|
||||
|
||||
return await resize(localUri, opts)
|
||||
return await doResize(localUri, opts)
|
||||
} finally {
|
||||
if (downloadRes) {
|
||||
downloadRes.flush()
|
||||
|
@ -63,79 +119,6 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
|||
}
|
||||
}
|
||||
|
||||
export interface ResizeOpts {
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
export async function resize(
|
||||
localUri: string,
|
||||
opts: ResizeOpts,
|
||||
): Promise<Image> {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const quality = 100 - i * 10
|
||||
const resizeRes = await ImageResizer.createResizedImage(
|
||||
localUri,
|
||||
opts.width,
|
||||
opts.height,
|
||||
'JPEG',
|
||||
quality,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{mode: opts.mode},
|
||||
)
|
||||
if (resizeRes.size < opts.maxSize) {
|
||||
return {
|
||||
path: resizeRes.path,
|
||||
mime: 'image/jpeg',
|
||||
size: resizeRes.size,
|
||||
width: resizeRes.width,
|
||||
height: resizeRes.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: Image,
|
||||
maxSize: number,
|
||||
): Promise<Image> {
|
||||
const origUri = `file://${img.path}`
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
}
|
||||
const resizedImage = await resize(origUri, {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
mode: 'stretch',
|
||||
maxSize,
|
||||
})
|
||||
const finalImageMovedPath = await moveToPremanantPath(resizedImage.path)
|
||||
const finalImg = {
|
||||
...resizedImage,
|
||||
path: finalImageMovedPath,
|
||||
}
|
||||
return finalImg
|
||||
}
|
||||
|
||||
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
||||
if (dim.width < max.width && dim.height < max.height) {
|
||||
return dim
|
||||
}
|
||||
let wScale = dim.width > max.width ? max.width / dim.width : 1
|
||||
let hScale = dim.height > max.height ? max.height / dim.height : 1
|
||||
if (wScale < hScale) {
|
||||
return {width: dim.width * wScale, height: dim.height * wScale}
|
||||
}
|
||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||
}
|
||||
|
||||
export async function saveImageModal({uri}: {uri: string}) {
|
||||
const downloadResponse = await RNFetchBlob.config({
|
||||
fileCache: true,
|
||||
|
@ -154,19 +137,7 @@ export async function saveImageModal({uri}: {uri: string}) {
|
|||
RNFS.unlink(imagePath)
|
||||
}
|
||||
|
||||
export async function moveToPremanantPath(path: string) {
|
||||
/*
|
||||
Since this package stores images in a temp directory, we need to move the file to a permanent location.
|
||||
Relevant: IOS bug when trying to open a second time:
|
||||
https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
|
||||
*/
|
||||
const filename = uuid.v4()
|
||||
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
|
||||
RNFS.moveFile(path, destinationPath)
|
||||
return destinationPath
|
||||
}
|
||||
|
||||
export function getImageDim(path: string): Promise<Dim> {
|
||||
export function getImageDim(path: string): Promise<Dimensions> {
|
||||
return new Promise((resolve, reject) => {
|
||||
RNImage.getSize(
|
||||
path,
|
||||
|
@ -177,3 +148,64 @@ export function getImageDim(path: string): Promise<Dim> {
|
|||
)
|
||||
})
|
||||
}
|
||||
|
||||
// internal methods
|
||||
// =
|
||||
|
||||
interface DoResizeOpts {
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const quality = 100 - i * 10
|
||||
const resizeRes = await ImageResizer.createResizedImage(
|
||||
localUri,
|
||||
opts.width,
|
||||
opts.height,
|
||||
'JPEG',
|
||||
quality,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{mode: opts.mode},
|
||||
)
|
||||
if (resizeRes.size < opts.maxSize) {
|
||||
return {
|
||||
path: normalizePath(resizeRes.path),
|
||||
mime: 'image/jpeg',
|
||||
size: resizeRes.size,
|
||||
width: resizeRes.width,
|
||||
height: resizeRes.height,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
|
||||
)
|
||||
}
|
||||
|
||||
async function moveToPermanentPath(path: string): Promise<string> {
|
||||
/*
|
||||
Since this package stores images in a temp directory, we need to move the file to a permanent location.
|
||||
Relevant: IOS bug when trying to open a second time:
|
||||
https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
|
||||
*/
|
||||
const filename = uuid.v4()
|
||||
|
||||
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
|
||||
await RNFS.moveFile(path, destinationPath)
|
||||
return normalizePath(destinationPath)
|
||||
}
|
||||
|
||||
function normalizePath(str: string): string {
|
||||
if (isAndroid) {
|
||||
if (!str.startsWith('file://')) {
|
||||
return `file://${str}`
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
|
|
@ -1,6 +1,40 @@
|
|||
// import {Share} from 'react-native'
|
||||
// import * as Toast from 'view/com/util/Toast'
|
||||
import {extractDataUriMime, getDataUriSize} from './util'
|
||||
import {Dimensions} from './types'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {getDataUriSize, blobToDataUri} from './util'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
|
||||
export async function compressAndResizeImageForPost({
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
}: {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
}): Promise<RNImage> {
|
||||
// Compression is handled in `doResize` via `quality`
|
||||
return await doResize(path, {
|
||||
width,
|
||||
height,
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
mode: 'stretch',
|
||||
})
|
||||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: RNImage,
|
||||
maxSize: number,
|
||||
): Promise<RNImage> {
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
}
|
||||
return await doResize(img.path, {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
mode: 'stretch',
|
||||
maxSize,
|
||||
})
|
||||
}
|
||||
|
||||
export interface DownloadAndResizeOpts {
|
||||
uri: string
|
||||
|
@ -11,14 +45,6 @@ export interface DownloadAndResizeOpts {
|
|||
timeout: number
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
path: string
|
||||
mime: string
|
||||
size: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
const controller = new AbortController()
|
||||
const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
|
||||
|
@ -27,58 +53,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
|||
clearTimeout(to)
|
||||
|
||||
const dataUri = await blobToDataUri(resBody)
|
||||
return await resize(dataUri, opts)
|
||||
}
|
||||
|
||||
export interface ResizeOpts {
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
export async function resize(
|
||||
dataUri: string,
|
||||
_opts: ResizeOpts,
|
||||
): Promise<Image> {
|
||||
const dim = await getImageDim(dataUri)
|
||||
// TODO -- need to resize
|
||||
return {
|
||||
path: dataUri,
|
||||
mime: extractDataUriMime(dataUri),
|
||||
size: getDataUriSize(dataUri),
|
||||
width: dim.width,
|
||||
height: dim.height,
|
||||
}
|
||||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: Image,
|
||||
maxSize: number,
|
||||
): Promise<Image> {
|
||||
if (img.size > maxSize) {
|
||||
// TODO
|
||||
throw new Error(
|
||||
"This image is too large and we haven't implemented compression yet -- sorry!",
|
||||
)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
export interface Dim {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
||||
if (dim.width < max.width && dim.height < max.height) {
|
||||
return dim
|
||||
}
|
||||
let wScale = dim.width > max.width ? max.width / dim.width : 1
|
||||
let hScale = dim.height > max.height ? max.height / dim.height : 1
|
||||
if (wScale < hScale) {
|
||||
return {width: dim.width * wScale, height: dim.height * wScale}
|
||||
}
|
||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||
return await doResize(dataUri, opts)
|
||||
}
|
||||
|
||||
export async function saveImageModal(_opts: {uri: string}) {
|
||||
|
@ -86,11 +61,7 @@ export async function saveImageModal(_opts: {uri: string}) {
|
|||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function moveToPremanantPath(path: string) {
|
||||
return path
|
||||
}
|
||||
|
||||
export async function getImageDim(path: string): Promise<Dim> {
|
||||
export async function getImageDim(path: string): Promise<Dimensions> {
|
||||
var img = document.createElement('img')
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
|
@ -101,17 +72,82 @@ export async function getImageDim(path: string): Promise<Dim> {
|
|||
return {width: img.width, height: img.height}
|
||||
}
|
||||
|
||||
function blobToDataUri(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result)
|
||||
} else {
|
||||
reject(new Error('Failed to read blob'))
|
||||
}
|
||||
// internal methods
|
||||
// =
|
||||
|
||||
interface DoResizeOpts {
|
||||
width: number
|
||||
height: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
maxSize: number
|
||||
}
|
||||
|
||||
async function doResize(dataUri: string, opts: DoResizeOpts): Promise<RNImage> {
|
||||
let newDataUri
|
||||
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
newDataUri = await createResizedImage(dataUri, {
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
quality: 1 - i * 0.1,
|
||||
mode: opts.mode,
|
||||
})
|
||||
if (getDataUriSize(newDataUri) < opts.maxSize) {
|
||||
break
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
}
|
||||
if (!newDataUri) {
|
||||
throw new Error('Failed to compress image')
|
||||
}
|
||||
return {
|
||||
path: newDataUri,
|
||||
mime: 'image/jpeg',
|
||||
size: getDataUriSize(newDataUri),
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
}
|
||||
}
|
||||
|
||||
function createResizedImage(
|
||||
dataUri: string,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
quality,
|
||||
mode,
|
||||
}: {
|
||||
width: number
|
||||
height: number
|
||||
quality: number
|
||||
mode: 'contain' | 'cover' | 'stretch'
|
||||
},
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = document.createElement('img')
|
||||
img.addEventListener('load', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
return reject(new Error('Failed to resize image'))
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
let scale = 1
|
||||
if (mode === 'cover') {
|
||||
scale = img.width < img.height ? width / img.width : height / img.height
|
||||
} else if (mode === 'contain') {
|
||||
scale = img.width > img.height ? width / img.width : height / img.height
|
||||
}
|
||||
let w = img.width * scale
|
||||
let h = img.height * scale
|
||||
let x = (width - w) / 2
|
||||
let y = (height - h) / 2
|
||||
|
||||
ctx.drawImage(img, x, y, w, h)
|
||||
resolve(canvas.toDataURL('image/jpeg', quality))
|
||||
})
|
||||
img.src = dataUri
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
import {RootStoreModel} from 'state/index'
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
import {
|
||||
scaleDownDimensions,
|
||||
Dim,
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
} from 'lib/media/manip'
|
||||
export type {PickedMedia} from './types'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import RNFS from 'react-native-fs'
|
||||
import {CropperOptions} from './types'
|
||||
import {compressAndResizeImageForPost} from './manip'
|
||||
|
||||
let _imageCounter = 0
|
||||
async function getFile() {
|
||||
|
@ -17,100 +12,33 @@ async function getFile() {
|
|||
.concat(['Media', 'DCIM', '100APPLE'])
|
||||
.join('/'),
|
||||
)
|
||||
return files[_imageCounter++ % files.length]
|
||||
}
|
||||
|
||||
export async function openPicker(
|
||||
_store: RootStoreModel,
|
||||
opts: PickerOpts,
|
||||
): Promise<PickedMedia[]> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
const items = await getFile()
|
||||
const toMedia = (item: RNFS.ReadDirItem) => ({
|
||||
mediaType,
|
||||
path: item.path,
|
||||
const file = files[_imageCounter++ % files.length]
|
||||
return await compressAndResizeImageForPost({
|
||||
path: file.path,
|
||||
mime: 'image/jpeg',
|
||||
size: item.size,
|
||||
size: file.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
})
|
||||
if (Array.isArray(items)) {
|
||||
return items.map(toMedia)
|
||||
}
|
||||
return [toMedia(items)]
|
||||
}
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
opts: CameraOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
const item = await getFile()
|
||||
return {
|
||||
mediaType,
|
||||
path: item.path,
|
||||
mime: 'image/jpeg',
|
||||
size: item.size,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
}
|
||||
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
|
||||
return [await getFile()]
|
||||
}
|
||||
|
||||
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
|
||||
return await getFile()
|
||||
}
|
||||
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
const item = await getFile()
|
||||
opts: CropperOptions,
|
||||
): Promise<RNImage> {
|
||||
return {
|
||||
mediaType,
|
||||
path: item.path,
|
||||
path: opts.path,
|
||||
mime: 'image/jpeg',
|
||||
size: item.size,
|
||||
size: 123,
|
||||
width: 4288,
|
||||
height: 2848,
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickImagesFlow(
|
||||
store: RootStoreModel,
|
||||
maxFiles: number,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
const items = await openPicker(store, {
|
||||
multiple: true,
|
||||
maxFiles,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
const result = []
|
||||
for (const image of items) {
|
||||
result.push(
|
||||
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function cropAndCompressFlow(
|
||||
store: RootStoreModel,
|
||||
path: string,
|
||||
imgDim: Dim,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(imgDim, maxDim)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const img = await compressIfNeeded(cropperRes, maxSize)
|
||||
const permanentPath = await moveToPremanantPath(img.path)
|
||||
return permanentPath
|
||||
}
|
||||
|
|
|
@ -5,14 +5,8 @@ import {
|
|||
ImageOrVideo,
|
||||
} from 'react-native-image-crop-picker'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
import {
|
||||
scaleDownDimensions,
|
||||
Dim,
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
} from 'lib/media/manip'
|
||||
export type {PickedMedia} from './types'
|
||||
import {PickerOpts, CameraOpts, CropperOptions} from './types'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
|
||||
/**
|
||||
* NOTE
|
||||
|
@ -25,18 +19,17 @@ export type {PickedMedia} from './types'
|
|||
|
||||
export async function openPicker(
|
||||
_store: RootStoreModel,
|
||||
opts: PickerOpts,
|
||||
): Promise<PickedMedia[]> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
opts?: PickerOpts,
|
||||
): Promise<RNImage[]> {
|
||||
const items = await openPickerFn({
|
||||
mediaType,
|
||||
multiple: opts.multiple,
|
||||
maxFiles: opts.maxFiles,
|
||||
mediaType: 'photo', // TODO: eventually add other media types
|
||||
multiple: opts?.multiple,
|
||||
maxFiles: opts?.maxFiles,
|
||||
forceJpg: true, // ios only
|
||||
compressImageQuality: 0.8,
|
||||
})
|
||||
|
||||
const toMedia = (item: ImageOrVideo) => ({
|
||||
mediaType,
|
||||
path: item.path,
|
||||
mime: item.mime,
|
||||
size: item.size,
|
||||
|
@ -52,20 +45,17 @@ export async function openPicker(
|
|||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
opts: CameraOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
): Promise<RNImage> {
|
||||
const item = await openCameraFn({
|
||||
mediaType,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
freeStyleCropEnabled: opts.freeStyleCropEnabled,
|
||||
cropperCircleOverlay: opts.cropperCircleOverlay,
|
||||
cropping: true,
|
||||
cropping: false,
|
||||
forceJpg: true, // ios only
|
||||
compressImageQuality: 0.8,
|
||||
})
|
||||
return {
|
||||
mediaType,
|
||||
path: item.path,
|
||||
mime: item.mime,
|
||||
size: item.size,
|
||||
|
@ -76,21 +66,15 @@ export async function openCamera(
|
|||
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
opts: CropperOpts,
|
||||
): Promise<PickedMedia> {
|
||||
const mediaType = opts.mediaType || 'photo'
|
||||
opts: CropperOptions,
|
||||
): Promise<RNImage> {
|
||||
const item = await openCropperFn({
|
||||
path: opts.path,
|
||||
mediaType: opts.mediaType || 'photo',
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
freeStyleCropEnabled: opts.freeStyleCropEnabled,
|
||||
cropperCircleOverlay: opts.cropperCircleOverlay,
|
||||
...opts,
|
||||
forceJpg: true, // ios only
|
||||
compressImageQuality: 0.8,
|
||||
})
|
||||
|
||||
return {
|
||||
mediaType,
|
||||
path: item.path,
|
||||
mime: item.mime,
|
||||
size: item.size,
|
||||
|
@ -98,46 +82,3 @@ export async function openCropper(
|
|||
height: item.height,
|
||||
}
|
||||
}
|
||||
|
||||
export async function pickImagesFlow(
|
||||
store: RootStoreModel,
|
||||
maxFiles: number,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
const items = await openPicker(store, {
|
||||
multiple: true,
|
||||
maxFiles,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
const result = []
|
||||
for (const image of items) {
|
||||
result.push(
|
||||
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function cropAndCompressFlow(
|
||||
store: RootStoreModel,
|
||||
path: string,
|
||||
imgDim: Dim,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(imgDim, maxDim)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const img = await compressIfNeeded(cropperRes, maxSize)
|
||||
const permanentPath = await moveToPremanantPath(img.path)
|
||||
return permanentPath
|
||||
}
|
||||
|
|
|
@ -1,16 +1,10 @@
|
|||
/// <reference lib="dom" />
|
||||
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
export type {PickedMedia} from './types'
|
||||
import {PickerOpts, CameraOpts, CropperOptions} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {
|
||||
scaleDownDimensions,
|
||||
getImageDim,
|
||||
Dim,
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
} from 'lib/media/manip'
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
import {extractDataUriMime} from './util'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
|
||||
interface PickedFile {
|
||||
uri: string
|
||||
|
@ -21,13 +15,12 @@ interface PickedFile {
|
|||
export async function openPicker(
|
||||
_store: RootStoreModel,
|
||||
opts: PickerOpts,
|
||||
): Promise<PickedMedia[]> {
|
||||
): Promise<RNImage[]> {
|
||||
const res = await selectFile(opts)
|
||||
const dim = await getImageDim(res.uri)
|
||||
const mime = extractDataUriMime(res.uri)
|
||||
return [
|
||||
{
|
||||
mediaType: 'photo',
|
||||
path: res.uri,
|
||||
mime,
|
||||
size: res.size,
|
||||
|
@ -40,21 +33,21 @@ export async function openPicker(
|
|||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
_opts: CameraOpts,
|
||||
): Promise<PickedMedia> {
|
||||
): Promise<RNImage> {
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function openCropper(
|
||||
store: RootStoreModel,
|
||||
opts: CropperOpts,
|
||||
): Promise<PickedMedia> {
|
||||
opts: CropperOptions,
|
||||
): Promise<RNImage> {
|
||||
// TODO handle more opts
|
||||
return new Promise((resolve, reject) => {
|
||||
store.shell.openModal({
|
||||
name: 'crop-image',
|
||||
uri: opts.path,
|
||||
onSelect: (img?: PickedMedia) => {
|
||||
onSelect: (img?: RNImage) => {
|
||||
if (img) {
|
||||
resolve(img)
|
||||
} else {
|
||||
|
@ -65,52 +58,6 @@ export async function openCropper(
|
|||
})
|
||||
}
|
||||
|
||||
export async function pickImagesFlow(
|
||||
store: RootStoreModel,
|
||||
maxFiles: number,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
const items = await openPicker(store, {
|
||||
multiple: true,
|
||||
maxFiles,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
const result = []
|
||||
for (const image of items) {
|
||||
result.push(
|
||||
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function cropAndCompressFlow(
|
||||
store: RootStoreModel,
|
||||
path: string,
|
||||
imgDim: Dim,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(imgDim, maxDim)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const img = await compressIfNeeded(cropperRes, maxSize)
|
||||
const permanentPath = await moveToPremanantPath(img.path)
|
||||
return permanentPath
|
||||
}
|
||||
|
||||
// helpers
|
||||
// =
|
||||
|
||||
/**
|
||||
* Opens the select file dialog in the browser.
|
||||
* NOTE:
|
||||
|
|
|
@ -1,31 +1,21 @@
|
|||
import {openCropper} from 'react-native-image-crop-picker'
|
||||
|
||||
export interface Dimensions {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface PickerOpts {
|
||||
mediaType?: 'photo'
|
||||
mediaType?: string
|
||||
multiple?: boolean
|
||||
maxFiles?: number
|
||||
}
|
||||
|
||||
export interface CameraOpts {
|
||||
mediaType?: 'photo'
|
||||
width: number
|
||||
height: number
|
||||
freeStyleCropEnabled?: boolean
|
||||
cropperCircleOverlay?: boolean
|
||||
}
|
||||
|
||||
export interface CropperOpts {
|
||||
path: string
|
||||
mediaType?: 'photo'
|
||||
width: number
|
||||
height: number
|
||||
freeStyleCropEnabled?: boolean
|
||||
cropperCircleOverlay?: boolean
|
||||
}
|
||||
|
||||
export interface PickedMedia {
|
||||
mediaType: 'photo'
|
||||
path: string
|
||||
mime: string
|
||||
size: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
export type CropperOptions = Parameters<typeof openCropper>[0]
|
||||
|
|
|
@ -1,7 +1,45 @@
|
|||
import {Dimensions} from './types'
|
||||
|
||||
export function extractDataUriMime(uri: string): string {
|
||||
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
||||
}
|
||||
|
||||
// Fairly accurate estimate that is more performant
|
||||
// than decoding and checking length of URI
|
||||
export function getDataUriSize(uri: string): number {
|
||||
return Math.round((uri.length * 3) / 4) // very rough estimate
|
||||
return Math.round((uri.length * 3) / 4)
|
||||
}
|
||||
|
||||
export function scaleDownDimensions(
|
||||
dim: Dimensions,
|
||||
max: Dimensions,
|
||||
): Dimensions {
|
||||
if (dim.width < max.width && dim.height < max.height) {
|
||||
return dim
|
||||
}
|
||||
const wScale = dim.width > max.width ? max.width / dim.width : 1
|
||||
const hScale = dim.height > max.height ? max.height / dim.height : 1
|
||||
if (wScale < hScale) {
|
||||
return {width: dim.width * wScale, height: dim.height * wScale}
|
||||
}
|
||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||
}
|
||||
|
||||
export function isUriImage(uri: string) {
|
||||
return /\.(jpg|jpeg|png).*$/.test(uri)
|
||||
}
|
||||
|
||||
export function blobToDataUri(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result)
|
||||
} else {
|
||||
reject(new Error('Failed to read blob'))
|
||||
}
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue