MobX removal take 2 (#5381)
* mobx removal take 2 * Actually rm mobx --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
cacheDirectory,
|
||||
deleteAsync,
|
||||
makeDirectoryAsync,
|
||||
moveAsync,
|
||||
} from 'expo-file-system'
|
||||
import {
|
||||
Action,
|
||||
ActionCrop,
|
||||
manipulateAsync,
|
||||
SaveFormat,
|
||||
} from 'expo-image-manipulator'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {getImageDim} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
|
||||
export type ImageTransformation = {
|
||||
crop?: ActionCrop['crop']
|
||||
}
|
||||
|
||||
export type ImageMeta = {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
mime: string
|
||||
}
|
||||
|
||||
export type ImageSource = ImageMeta & {
|
||||
id: string
|
||||
}
|
||||
|
||||
type ComposerImageBase = {
|
||||
alt: string
|
||||
source: ImageSource
|
||||
}
|
||||
type ComposerImageWithoutTransformation = ComposerImageBase & {
|
||||
transformed?: undefined
|
||||
manips?: undefined
|
||||
}
|
||||
type ComposerImageWithTransformation = ComposerImageBase & {
|
||||
transformed: ImageMeta
|
||||
manips?: ImageTransformation
|
||||
}
|
||||
|
||||
export type ComposerImage =
|
||||
| ComposerImageWithoutTransformation
|
||||
| ComposerImageWithTransformation
|
||||
|
||||
let _imageCacheDirectory: string
|
||||
|
||||
function getImageCacheDirectory(): string | null {
|
||||
if (isNative) {
|
||||
return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createComposerImage(
|
||||
raw: ImageMeta,
|
||||
): Promise<ComposerImageWithoutTransformation> {
|
||||
return {
|
||||
alt: '',
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path: await moveIfNecessary(raw.path),
|
||||
width: raw.width,
|
||||
height: raw.height,
|
||||
mime: raw.mime,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type InitialImage = {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
export function createInitialImages(
|
||||
uris: InitialImage[] = [],
|
||||
): ComposerImageWithoutTransformation[] {
|
||||
return uris.map(({uri, width, height, altText = ''}) => {
|
||||
return {
|
||||
alt: altText,
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path: uri,
|
||||
width: width,
|
||||
height: height,
|
||||
mime: 'image/jpeg',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function pasteImage(
|
||||
uri: string,
|
||||
): Promise<ComposerImageWithoutTransformation> {
|
||||
const {width, height} = await getImageDim(uri)
|
||||
const match = /^data:(.+?);/.exec(uri)
|
||||
|
||||
return {
|
||||
alt: '',
|
||||
source: {
|
||||
id: nanoid(),
|
||||
path: uri,
|
||||
width: width,
|
||||
height: height,
|
||||
mime: match ? match[1] : 'image/jpeg',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
|
||||
if (!isNative) {
|
||||
return img
|
||||
}
|
||||
|
||||
// NOTE
|
||||
// on ios, react-native-image-crop-picker gives really bad quality
|
||||
// without specifying width and height. on android, however, the
|
||||
// crop stretches incorrectly if you do specify it. these are
|
||||
// both separate bugs in the library. we deal with that by
|
||||
// providing width & height for ios only
|
||||
// -prf
|
||||
|
||||
const source = img.source
|
||||
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||
|
||||
// @todo: we're always passing the original image here, does image-cropper
|
||||
// allows for setting initial crop dimensions? -mary
|
||||
try {
|
||||
const cropped = await openCropper({
|
||||
mediaType: 'photo',
|
||||
path: source.path,
|
||||
freeStyleCropEnabled: true,
|
||||
...(isIOS ? {width: w, height: h} : {}),
|
||||
})
|
||||
|
||||
return {
|
||||
alt: img.alt,
|
||||
source: source,
|
||||
transformed: {
|
||||
path: await moveIfNecessary(cropped.path),
|
||||
width: cropped.width,
|
||||
height: cropped.height,
|
||||
mime: cropped.mime,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('User cancelled')) {
|
||||
return img
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function manipulateImage(
|
||||
img: ComposerImage,
|
||||
trans: ImageTransformation,
|
||||
): Promise<ComposerImage> {
|
||||
const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
|
||||
|
||||
const actions = rawActions.filter((a): a is Action => a !== undefined)
|
||||
|
||||
if (actions.length === 0) {
|
||||
if (img.transformed === undefined) {
|
||||
return img
|
||||
}
|
||||
|
||||
return {alt: img.alt, source: img.source}
|
||||
}
|
||||
|
||||
const source = img.source
|
||||
const result = await manipulateAsync(source.path, actions, {
|
||||
format: SaveFormat.PNG,
|
||||
})
|
||||
|
||||
return {
|
||||
alt: img.alt,
|
||||
source: img.source,
|
||||
transformed: {
|
||||
path: await moveIfNecessary(result.uri),
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
mime: 'image/png',
|
||||
},
|
||||
manips: trans,
|
||||
}
|
||||
}
|
||||
|
||||
export function resetImageManipulation(
|
||||
img: ComposerImage,
|
||||
): ComposerImageWithoutTransformation {
|
||||
if (img.transformed !== undefined) {
|
||||
return {alt: img.alt, source: img.source}
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
|
||||
const source = img.transformed || img.source
|
||||
|
||||
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||
const cacheDir = isNative && getImageCacheDirectory()
|
||||
|
||||
for (let i = 10; i > 0; i--) {
|
||||
// Float precision
|
||||
const factor = i / 10
|
||||
|
||||
const res = await manipulateAsync(
|
||||
source.path,
|
||||
[{resize: {width: w, height: h}}],
|
||||
{
|
||||
compress: factor,
|
||||
format: SaveFormat.JPEG,
|
||||
base64: true,
|
||||
},
|
||||
)
|
||||
|
||||
const base64 = res.base64
|
||||
|
||||
if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
|
||||
return {
|
||||
path: await moveIfNecessary(res.uri),
|
||||
width: res.width,
|
||||
height: res.height,
|
||||
mime: 'image/jpeg',
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheDir) {
|
||||
await deleteAsync(res.uri)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to compress image`)
|
||||
}
|
||||
|
||||
async function moveIfNecessary(from: string) {
|
||||
const cacheDir = isNative && getImageCacheDirectory()
|
||||
|
||||
if (cacheDir && from.startsWith(cacheDir)) {
|
||||
const to = joinPath(cacheDir, nanoid(36))
|
||||
|
||||
await makeDirectoryAsync(cacheDir, {intermediates: true})
|
||||
await moveAsync({from, to})
|
||||
|
||||
return to
|
||||
}
|
||||
|
||||
return from
|
||||
}
|
||||
|
||||
/** Purge files that were created to accomodate image manipulation */
|
||||
export async function purgeTemporaryImageFiles() {
|
||||
const cacheDir = isNative && getImageCacheDirectory()
|
||||
|
||||
if (cacheDir) {
|
||||
await deleteAsync(cacheDir, {idempotent: true})
|
||||
await makeDirectoryAsync(cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
function joinPath(a: string, b: string) {
|
||||
if (a.endsWith('/')) {
|
||||
if (b.startsWith('/')) {
|
||||
return a.slice(0, -1) + b
|
||||
}
|
||||
return a + b
|
||||
} else if (b.startsWith('/')) {
|
||||
return a + b
|
||||
}
|
||||
return a + '/' + b
|
||||
}
|
||||
|
||||
function containImageRes(
|
||||
w: number,
|
||||
h: number,
|
||||
{width: maxW, height: maxH}: {width: number; height: number},
|
||||
): [width: number, height: number] {
|
||||
let scale = 1
|
||||
|
||||
if (w > maxW || h > maxH) {
|
||||
scale = w > h ? maxW / w : maxH / h
|
||||
w = Math.floor(w * scale)
|
||||
h = Math.floor(h * scale)
|
||||
}
|
||||
|
||||
return [w, h]
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {GalleryModel} from '#/state/models/media/gallery'
|
||||
import {ImageModel} from '#/state/models/media/image'
|
||||
import {ComposerImage} from '../gallery'
|
||||
|
||||
export interface EditProfileModal {
|
||||
name: 'edit-profile'
|
||||
@@ -37,12 +36,6 @@ export interface ListAddRemoveUsersModal {
|
||||
) => void
|
||||
}
|
||||
|
||||
export interface EditImageModal {
|
||||
name: 'edit-image'
|
||||
image: ImageModel
|
||||
gallery: GalleryModel
|
||||
}
|
||||
|
||||
export interface CropImageModal {
|
||||
name: 'crop-image'
|
||||
uri: string
|
||||
@@ -52,7 +45,8 @@ export interface CropImageModal {
|
||||
|
||||
export interface AltTextImageModal {
|
||||
name: 'alt-text-image'
|
||||
image: ImageModel
|
||||
image: ComposerImage
|
||||
onChange: (next: ComposerImage) => void
|
||||
}
|
||||
|
||||
export interface DeleteAccountModal {
|
||||
@@ -139,7 +133,6 @@ export type Modal =
|
||||
// Posts
|
||||
| AltTextImageModal
|
||||
| CropImageModal
|
||||
| EditImageModal
|
||||
| SelfLabelModal
|
||||
|
||||
// Bluesky access
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
import {openPicker} from 'lib/media/picker'
|
||||
import {ImageInitOptions, ImageModel} from './image'
|
||||
|
||||
interface InitialImageUri {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
export class GalleryModel {
|
||||
images: ImageModel[] = []
|
||||
|
||||
constructor(uris?: InitialImageUri[]) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
if (uris) {
|
||||
this.addFromUris(uris)
|
||||
}
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.size === 0
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.images.length
|
||||
}
|
||||
|
||||
get needsAltText() {
|
||||
return this.images.some(image => image.altText.trim() === '')
|
||||
}
|
||||
|
||||
*add(image_: ImageInitOptions) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
|
||||
// Temporarily enforce uniqueness but can eventually also use index
|
||||
if (!this.images.some(i => i.path === image_.path)) {
|
||||
const image = new ImageModel(image_)
|
||||
|
||||
// Initial resize
|
||||
image.manipulate({})
|
||||
this.images.push(image)
|
||||
}
|
||||
}
|
||||
|
||||
async paste(uri: string) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
|
||||
const {width, height} = await getImageDim(uri)
|
||||
|
||||
const image = {
|
||||
path: uri,
|
||||
height,
|
||||
width,
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.add(image)
|
||||
})
|
||||
}
|
||||
|
||||
setAltText(image: ImageModel, altText: string) {
|
||||
image.setAltText(altText)
|
||||
}
|
||||
|
||||
crop(image: ImageModel) {
|
||||
image.crop()
|
||||
}
|
||||
|
||||
remove(image: ImageModel) {
|
||||
const index = this.images.findIndex(image_ => image_.path === image.path)
|
||||
this.images.splice(index, 1)
|
||||
}
|
||||
|
||||
async previous(image: ImageModel) {
|
||||
image.previous()
|
||||
}
|
||||
|
||||
async pick() {
|
||||
const images = await openPicker({
|
||||
selectionLimit: 4 - this.size,
|
||||
allowsMultipleSelection: true,
|
||||
})
|
||||
|
||||
return await Promise.all(
|
||||
images.map(image => {
|
||||
this.add(image)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async addFromUris(uris: InitialImageUri[]) {
|
||||
for (const uriObj of uris) {
|
||||
this.add({
|
||||
height: uriObj.height,
|
||||
width: uriObj.width,
|
||||
path: uriObj.uri,
|
||||
altText: uriObj.altText,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {makeAutoObservable} from 'mobx'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {ActionCrop} from 'expo-image-manipulator'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
rotate?: number
|
||||
scale?: number
|
||||
position?: Position
|
||||
flipHorizontal?: boolean
|
||||
flipVertical?: boolean
|
||||
}
|
||||
|
||||
export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
path: string
|
||||
mime = 'image/jpeg'
|
||||
width: number
|
||||
height: number
|
||||
altText = ''
|
||||
cropped?: RNImage = undefined
|
||||
compressed?: RNImage = undefined
|
||||
|
||||
// Web manipulation
|
||||
prev?: RNImage
|
||||
attributes: ImageManipulationAttributes = {
|
||||
aspectRatio: 'None',
|
||||
scale: 1,
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
rotate: 0,
|
||||
}
|
||||
prevAttributes: ImageManipulationAttributes = {}
|
||||
|
||||
constructor(image: Omit<RNImage, 'size'>) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
this.path = image.path
|
||||
this.width = image.width
|
||||
this.height = image.height
|
||||
}
|
||||
|
||||
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
setRotate(degrees: number) {
|
||||
this.attributes.rotate = degrees
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipVertical() {
|
||||
this.attributes.flipVertical = !this.attributes.flipVertical
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipHorizontal() {
|
||||
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
get ratioMultipliers() {
|
||||
return {
|
||||
'4:3': 4 / 3,
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
None: this.width / this.height,
|
||||
}
|
||||
}
|
||||
|
||||
getUploadDimensions(
|
||||
dimensions: Dimensions,
|
||||
maxDimensions: Dimensions = POST_IMG_MAX,
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
) {
|
||||
const {width, height} = dimensions
|
||||
const {width: maxWidth, height: maxHeight} = maxDimensions
|
||||
|
||||
return width < maxWidth && height < maxHeight
|
||||
? {
|
||||
width,
|
||||
height,
|
||||
}
|
||||
: this.getResizedDimensions(as, POST_IMG_MAX.width)
|
||||
}
|
||||
|
||||
getResizedDimensions(
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
maxSide: number,
|
||||
) {
|
||||
const ratioMultiplier = this.ratioMultipliers[as]
|
||||
|
||||
if (ratioMultiplier === 1) {
|
||||
return {
|
||||
height: maxSide,
|
||||
width: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
if (ratioMultiplier < 1) {
|
||||
return {
|
||||
width: maxSide * ratioMultiplier,
|
||||
height: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: maxSide,
|
||||
height: maxSide / ratioMultiplier,
|
||||
}
|
||||
}
|
||||
|
||||
setAltText(altText: string) {
|
||||
this.altText = altText.trim()
|
||||
}
|
||||
|
||||
// Only compress prior to upload
|
||||
async compress() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async crop() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
// Web manipulation
|
||||
async manipulate(
|
||||
_attributes: {
|
||||
crop?: ActionCrop['crop']
|
||||
} & ImageManipulationAttributes,
|
||||
) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
resetCropped() {
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.cropped = this.prev
|
||||
this.attributes = this.prevAttributes
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import * as ImageManipulator from 'expo-image-manipulator'
|
||||
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {openCropper} from 'lib/media/picker'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
rotate?: number
|
||||
scale?: number
|
||||
position?: Position
|
||||
flipHorizontal?: boolean
|
||||
flipVertical?: boolean
|
||||
}
|
||||
|
||||
export interface ImageInitOptions {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
const MAX_IMAGE_SIZE_IN_BYTES = 976560
|
||||
|
||||
export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
path: string
|
||||
mime = 'image/jpeg'
|
||||
width: number
|
||||
height: number
|
||||
altText = ''
|
||||
cropped?: RNImage = undefined
|
||||
compressed?: RNImage = undefined
|
||||
|
||||
// Web manipulation
|
||||
prev?: RNImage
|
||||
attributes: ImageManipulationAttributes = {
|
||||
aspectRatio: 'None',
|
||||
scale: 1,
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
rotate: 0,
|
||||
}
|
||||
prevAttributes: ImageManipulationAttributes = {}
|
||||
|
||||
constructor(image: ImageInitOptions) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
this.path = image.path
|
||||
this.width = image.width
|
||||
this.height = image.height
|
||||
if (image.altText !== undefined) {
|
||||
this.setAltText(image.altText)
|
||||
}
|
||||
}
|
||||
|
||||
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
setRotate(degrees: number) {
|
||||
this.attributes.rotate = degrees
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipVertical() {
|
||||
this.attributes.flipVertical = !this.attributes.flipVertical
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
flipHorizontal() {
|
||||
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
get ratioMultipliers() {
|
||||
return {
|
||||
'4:3': 4 / 3,
|
||||
'1:1': 1,
|
||||
'3:4': 3 / 4,
|
||||
None: this.width / this.height,
|
||||
}
|
||||
}
|
||||
|
||||
getUploadDimensions(
|
||||
dimensions: Dimensions,
|
||||
maxDimensions: Dimensions = POST_IMG_MAX,
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
) {
|
||||
const {width, height} = dimensions
|
||||
const {width: maxWidth, height: maxHeight} = maxDimensions
|
||||
|
||||
return width < maxWidth && height < maxHeight
|
||||
? {
|
||||
width,
|
||||
height,
|
||||
}
|
||||
: this.getResizedDimensions(as, POST_IMG_MAX.width)
|
||||
}
|
||||
|
||||
getResizedDimensions(
|
||||
as: ImageManipulationAttributes['aspectRatio'] = 'None',
|
||||
maxSide: number,
|
||||
) {
|
||||
const ratioMultiplier = this.ratioMultipliers[as]
|
||||
|
||||
if (ratioMultiplier === 1) {
|
||||
return {
|
||||
height: maxSide,
|
||||
width: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
if (ratioMultiplier < 1) {
|
||||
return {
|
||||
width: maxSide * ratioMultiplier,
|
||||
height: maxSide,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: maxSide,
|
||||
height: maxSide / ratioMultiplier,
|
||||
}
|
||||
}
|
||||
|
||||
setAltText(altText: string) {
|
||||
this.altText = altText.trim()
|
||||
}
|
||||
|
||||
// Only compress prior to upload
|
||||
async compress() {
|
||||
for (let i = 10; i > 0; i--) {
|
||||
// Float precision
|
||||
const factor = Math.round(i) / 10
|
||||
const compressed = await ImageManipulator.manipulateAsync(
|
||||
this.cropped?.path ?? this.path,
|
||||
undefined,
|
||||
{
|
||||
compress: factor,
|
||||
base64: true,
|
||||
format: SaveFormat.JPEG,
|
||||
},
|
||||
)
|
||||
|
||||
if (compressed.base64 !== undefined) {
|
||||
const size = getDataUriSize(compressed.base64)
|
||||
|
||||
if (size < MAX_IMAGE_SIZE_IN_BYTES) {
|
||||
runInAction(() => {
|
||||
this.compressed = {
|
||||
mime: 'image/jpeg',
|
||||
path: compressed.uri,
|
||||
size,
|
||||
...compressed,
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compression fails when removing redundant information is not possible.
|
||||
// This can be tested with images that have high variance in noise.
|
||||
throw new Error('Failed to compress image')
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async crop() {
|
||||
try {
|
||||
// NOTE
|
||||
// on ios, react-native-image-crop-picker gives really bad quality
|
||||
// without specifying width and height. on android, however, the
|
||||
// crop stretches incorrectly if you do specify it. these are
|
||||
// both separate bugs in the library. we deal with that by
|
||||
// providing width & height for ios only
|
||||
// -prf
|
||||
const {width, height} = this.getUploadDimensions({
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
})
|
||||
|
||||
const cropped = await openCropper({
|
||||
mediaType: 'photo',
|
||||
path: this.path,
|
||||
freeStyleCropEnabled: true,
|
||||
...(isIOS ? {width, height} : {}),
|
||||
})
|
||||
|
||||
runInAction(() => {
|
||||
this.cropped = cropped
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to crop photo', {message: err})
|
||||
}
|
||||
}
|
||||
|
||||
// Web manipulation
|
||||
async manipulate(
|
||||
attributes: {
|
||||
crop?: ActionCrop['crop']
|
||||
} & ImageManipulationAttributes,
|
||||
) {
|
||||
let uploadWidth: number | undefined
|
||||
let uploadHeight: number | undefined
|
||||
|
||||
const {aspectRatio, crop, position, scale} = attributes
|
||||
const modifiers = []
|
||||
|
||||
if (this.attributes.flipHorizontal) {
|
||||
modifiers.push({flip: FlipType.Horizontal})
|
||||
}
|
||||
|
||||
if (this.attributes.flipVertical) {
|
||||
modifiers.push({flip: FlipType.Vertical})
|
||||
}
|
||||
|
||||
if (this.attributes.rotate !== undefined) {
|
||||
modifiers.push({rotate: this.attributes.rotate})
|
||||
}
|
||||
|
||||
if (crop !== undefined) {
|
||||
const croppedHeight = crop.height * this.height
|
||||
const croppedWidth = crop.width * this.width
|
||||
modifiers.push({
|
||||
crop: {
|
||||
originX: crop.originX * this.width,
|
||||
originY: crop.originY * this.height,
|
||||
height: croppedHeight,
|
||||
width: croppedWidth,
|
||||
},
|
||||
})
|
||||
|
||||
const uploadDimensions = this.getUploadDimensions(
|
||||
{width: croppedWidth, height: croppedHeight},
|
||||
POST_IMG_MAX,
|
||||
aspectRatio,
|
||||
)
|
||||
|
||||
uploadWidth = uploadDimensions.width
|
||||
uploadHeight = uploadDimensions.height
|
||||
} else {
|
||||
const uploadDimensions = this.getUploadDimensions(
|
||||
{width: this.width, height: this.height},
|
||||
POST_IMG_MAX,
|
||||
aspectRatio,
|
||||
)
|
||||
|
||||
uploadWidth = uploadDimensions.width
|
||||
uploadHeight = uploadDimensions.height
|
||||
}
|
||||
|
||||
if (scale !== undefined) {
|
||||
this.attributes.scale = scale
|
||||
}
|
||||
|
||||
if (position !== undefined) {
|
||||
this.attributes.position = position
|
||||
}
|
||||
|
||||
if (aspectRatio !== undefined) {
|
||||
this.attributes.aspectRatio = aspectRatio
|
||||
}
|
||||
|
||||
const ratioMultiplier =
|
||||
this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
|
||||
|
||||
const result = await ImageManipulator.manipulateAsync(
|
||||
this.path,
|
||||
[
|
||||
...modifiers,
|
||||
{
|
||||
resize:
|
||||
ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
|
||||
},
|
||||
],
|
||||
{
|
||||
base64: true,
|
||||
format: SaveFormat.JPEG,
|
||||
},
|
||||
)
|
||||
|
||||
runInAction(() => {
|
||||
this.cropped = {
|
||||
mime: 'image/jpeg',
|
||||
path: result.uri,
|
||||
size:
|
||||
result.base64 !== undefined
|
||||
? getDataUriSize(result.base64)
|
||||
: MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
|
||||
...result,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
resetCropped() {
|
||||
this.manipulate({})
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.cropped = this.prev
|
||||
this.attributes = this.prevAttributes
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {purgeTemporaryImageFiles} from '#/state/gallery'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
@@ -77,7 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
const closeComposer = useNonReactiveCallback(() => {
|
||||
let wasOpen = !!state
|
||||
setState(undefined)
|
||||
if (wasOpen) {
|
||||
setState(undefined)
|
||||
purgeTemporaryImageFiles()
|
||||
}
|
||||
|
||||
return wasOpen
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user