UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

213 lines (196 loc) • 6.53 kB
import { resolveCameraOptions, resolveImagePickerOptions } from './options' import type { ImagePickerAsset, ImagePickerOptions, ImagePickerPermissionResponse, ImagePickerResult, } from './types' export type * from './types' // web entry. the system picker is a file input; signatures stay identical // to the native entry because the published declarations are built from // this file and serve both platforms. no browser global is touched at // import, and on the server every launch behaves as no native side. const granted: ImagePickerPermissionResponse = Object.freeze({ status: 'granted', granted: true, canAskAgain: true, }) function acceptFor(mediaTypes: string[]): string { const accept: string[] = [] if (mediaTypes.includes('images')) accept.push('image/*') if (mediaTypes.includes('videos')) accept.push('video/*') return accept.join(',') } // a file dialog has no promise: change resolves with files and the cancel // event resolves with null. function pickFiles( accept: string, multiple: boolean, capture?: string ): Promise<File[] | null> { return new Promise((resolve) => { const input = document.createElement('input') input.type = 'file' input.accept = accept input.multiple = multiple if (capture) input.capture = capture input.onchange = () => resolve(input.files ? [...input.files] : []) input.addEventListener('cancel', () => resolve(null)) input.click() }) } async function imageSize(file: File): Promise<{ width: number; height: number }> { try { const bitmap = await createImageBitmap(file) const size = { width: bitmap.width, height: bitmap.height } bitmap.close() return size } catch { return { width: 0, height: 0 } } } function videoSize(url: string): Promise<{ width: number; height: number }> { return new Promise((resolve) => { const video = document.createElement('video') video.preload = 'metadata' video.muted = true video.onloadedmetadata = () => resolve({ width: video.videoWidth, height: video.videoHeight }) video.onerror = () => resolve({ width: 0, height: 0 }) video.src = url }) } function videoDuration(url: string): Promise<number | undefined> { return new Promise((resolve) => { const video = document.createElement('video') video.preload = 'metadata' video.muted = true video.onloadedmetadata = () => resolve( Number.isFinite(video.duration) ? Math.round(video.duration * 1000) : undefined ) video.onerror = () => resolve(undefined) video.src = url }) } async function toAsset(file: File, quality: number): Promise<ImagePickerAsset> { const uri = URL.createObjectURL(file) const isVideo = file.type.startsWith('video/') const asset: ImagePickerAsset = { uri, width: 0, height: 0, mimeType: file.type || undefined, fileName: file.name, fileSize: file.size, type: isVideo ? 'video' : 'image', } if (isVideo) { const url = uri const size = await videoSize(url) asset.width = size.width asset.height = size.height const duration = await videoDuration(url) if (duration !== undefined) asset.duration = duration return asset } if (quality < 1) { const compressed = await compressImage(file, quality) if (compressed) { URL.revokeObjectURL(uri) return compressed } } const size = await imageSize(file) asset.width = size.width asset.height = size.height return asset } // recompress an image file to jpeg at the requested quality. null when the // browser could not decode it, and the caller keeps the original. async function compressImage(file: File, quality: number): Promise<ImagePickerAsset | null> { try { const bitmap = await createImageBitmap(file) const canvas = document.createElement('canvas') canvas.width = bitmap.width canvas.height = bitmap.height const context = canvas.getContext('2d') if (!context) { bitmap.close() return null } context.drawImage(bitmap, 0, 0) bitmap.close() const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality) ) if (!blob) return null const uri = URL.createObjectURL(blob) return { uri, width: canvas.width, height: canvas.height, mimeType: 'image/jpeg', fileName: file.name, fileSize: blob.size, type: 'image', } } catch { return null } } async function pick( verb: string, selectionLimit: number, mediaTypes: string[], quality: number, capture?: string ): Promise<ImagePickerResult> { if (typeof document === 'undefined') { throw new Error(`ImagePicker.${verb} needs an iOS or Android build`) } const files = await pickFiles( acceptFor(mediaTypes), selectionLimit !== 1, capture ) if (!files || files.length === 0) return { canceled: true, assets: null } const limited = selectionLimit > 0 ? files.slice(0, selectionLimit) : files const assets: ImagePickerAsset[] = [] for (const file of limited) { assets.push(await toAsset(file, quality)) } return { canceled: false, assets } } // plain, not async, so bad options throw synchronously like the native entry. // the web file dialog has no crop UI: allowsEditing and aspect validate // here but change nothing. function launchLibrary( options: ImagePickerOptions = {} ): Promise<ImagePickerResult> { const resolved = resolveImagePickerOptions(options) return pick('launchLibrary', resolved.selectionLimit, resolved.mediaTypes, resolved.quality) } // mobile browsers open the camera for a capture input; desktop browsers // show the file dialog, the same fallback every expo web app ships. a // videos-only request captures video where the browser supports it. function launchCamera( options: ImagePickerOptions = {} ): Promise<ImagePickerResult> { const resolved = resolveCameraOptions(options) // selectionLimit is validated but ignored: the camera captures one asset. return pick('launchCamera', 1, resolved.mediaTypes, resolved.quality, 'environment') } // the web picker needs no grant, so both permission calls read granted. async function getCameraPermissions(): Promise<ImagePickerPermissionResponse> { return granted } async function requestCameraPermissions(): Promise<ImagePickerPermissionResponse> { return granted } export const ImagePicker = Object.freeze({ launchLibrary, launchCamera, getCameraPermissions, requestCameraPermissions, })