From 355d8c8e512b4cbd92ab833b5fb492b4f929593a Mon Sep 17 00:00:00 2001 From: Jerko Steiner Date: Sun, 9 Jun 2019 09:36:23 +1000 Subject: [PATCH] Add ability to resize images client-side --- packages/image-upload/src/Files.ts | 30 ++++++ packages/image-upload/src/Image.ts | 39 ++++++++ packages/image-upload/src/ImageUpload.tsx | 61 +++++++++--- packages/image-upload/src/Resizer.ts | 107 ++++++++++------------ 4 files changed, 163 insertions(+), 74 deletions(-) create mode 100644 packages/image-upload/src/Files.ts create mode 100644 packages/image-upload/src/Image.ts diff --git a/packages/image-upload/src/Files.ts b/packages/image-upload/src/Files.ts new file mode 100644 index 0000000..53565f9 --- /dev/null +++ b/packages/image-upload/src/Files.ts @@ -0,0 +1,30 @@ + +function attachErrorListener( + reader: FileReader, + file: File, + reject: (err: Error) => void, +) { + reader.onerror = ev => reject(new Error('Error reading file: ' + file.name)) +} + +export async function readAsDataURL(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + resolve(reader.result as string) + } + attachErrorListener(reader, file, reject) + reader.readAsDataURL(file) + }) +} + +export async function readAsArrayBuffer(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + resolve(reader.result as ArrayBuffer) + } + attachErrorListener(reader, file, reject) + reader.readAsArrayBuffer(file) + }) +} diff --git a/packages/image-upload/src/Image.ts b/packages/image-upload/src/Image.ts new file mode 100644 index 0000000..760fd56 --- /dev/null +++ b/packages/image-upload/src/Image.ts @@ -0,0 +1,39 @@ +export async function createImageFromDataURL(dataURL: string) + : Promise { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('Error reading image!')) + image.src = dataURL + }) +} + +export async function drawCanvasFromDataURL(dataURL: string) + : Promise { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + const image = await createImageFromDataURL(dataURL) + canvas.width = image.width + canvas.height = image.height + ctx.drawImage(image, 0, 0) + return canvas +} + +export async function getCanvasFromArrayBuffer( + arrayBuffer: ArrayBuffer, + width: number, + height: number, +) { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + const imageData = new ImageData( + new Uint8ClampedArray(arrayBuffer), + width, + height, + ) + canvas.width = width + canvas.height = height + ctx.putImageData(imageData, 0, 0) + }) +} diff --git a/packages/image-upload/src/ImageUpload.tsx b/packages/image-upload/src/ImageUpload.tsx index 5ca7306..558ab1a 100644 --- a/packages/image-upload/src/ImageUpload.tsx +++ b/packages/image-upload/src/ImageUpload.tsx @@ -1,7 +1,10 @@ import React from 'react' +import {Resizer} from './Resizer' +import {readAsDataURL} from './Files' +import {drawCanvasFromDataURL} from './Image' export interface IImage { - base64: string + dataURL: string } export interface IImageUploadProps { @@ -16,23 +19,55 @@ export class ImageUpload extends React.PureComponent { this.fileInput = React.createRef() } - handleChange = (event: React.SyntheticEvent) => { - const files = this.fileInput.current!.files! - - Array.prototype.forEach.call(files, (file: File) => { - const reader = new FileReader() - reader.addEventListener('load', function() { - const {result} = this - console.log(file.name, result) - // TODO resize image... - }) - reader.readAsDataURL(file) - }) + safeHandleChange = async (event: React.SyntheticEvent) => { + try { + await this.handleChange(event) + } catch (err) { + console.log('Error in handleChange', err) + } } + handleChange = async (event: React.SyntheticEvent) => { + const self = this + const files = Array.from(this.fileInput.current!.files!) + + const resized: IImage[] = [] + for (const file of files) { + const dataURL = await readAsDataURL(file) + const resizedDataURL = await this.resize(dataURL) + resized.push({ + dataURL: resizedDataURL, + }) + + // TODO testing stuff + const img = document.createElement('img') + img.src = resizedDataURL + this.fileInput.current!.parentElement!.appendChild(img) + } + + (window as any).resized = resized + this.props.onChange(resized) + } + + async resize(dataURL: string): Promise { + const canvas = await drawCanvasFromDataURL(dataURL) + + const maxHeight = 512 + // // TODO figure out what to do when the image is really wide + + if (canvas.height > maxHeight) { + const height = maxHeight + const width = Math.round(canvas.width / canvas.height * maxHeight) + await new Resizer().resample(canvas, width, height) + } + + return canvas.toDataURL('image/jpeg', .85) + } + render() { return (
void} { + root.onmessage = (event: IWorkerParamMessage) => { const sourceWidth = event.data.sourceWidth const sourceHeight = event.data.sourceHeight const width = event.data.width @@ -102,22 +93,26 @@ function ResizeWorker() { } const objData: IWorkerResult = { - core, target, } postMessage(objData, [target.buffer] as any) } + return root } export class Resizer { readonly cores = navigator.hardwareConcurrency || 4 readonly workerBlobURL = window.URL.createObjectURL( new Blob( - ['(', ResizeWorker.toString(), ')()'], + ['(', createResizeWorker.toString(), ')(self)'], {type: 'application/javascript'}, )) - async resample(canvas: HTMLCanvasElement, width: number, height: number) { + async resample( + canvas: HTMLCanvasElement, + width: number, + height: number, + ): Promise { const {cores} = this const sourceWidth = canvas.width const sourceHeight = canvas.height @@ -125,84 +120,74 @@ export class Resizer { height = Math.round(height) const ratioH = sourceHeight / height - const workers = new Array(cores) // TODO handle null const ctx = canvas.getContext('2d')! - // prepare source and target data for workers - const partitions: IPartition[] = new Array(cores) + let resolve: (canvas: HTMLCanvasElement) => void + let reject: (err: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + const blockHeight = Math.ceil(sourceHeight / cores / 2) * 2 let endY = -1 + let activeWorkers = 0 + const workers: Worker[] = [] for (let c = 0; c < cores; c++) { - // source const offsetY = endY + 1 if (offsetY >= sourceHeight) { // size too small, nothing left for this core continue } - endY = offsetY + blockHeight - 1 - endY = Math.min(endY, sourceHeight - 1) + endY = Math.min(offsetY + blockHeight - 1, sourceHeight - 1) - let currentBlockHeight = blockHeight - currentBlockHeight = Math.min(blockHeight, sourceHeight - offsetY) + const currentBlockHeight = Math.min(blockHeight, sourceHeight - offsetY) - // console.log( - // 'source split: ', '#'+c, offsetY, endY, 'height: '+currentBlockHeight); - - partitions[c] = { + const partition = { source: ctx.getImageData(0, offsetY, sourceWidth, blockHeight), target: ctx.createImageData( width, Math.ceil(currentBlockHeight / ratioH)), startY: Math.ceil(offsetY / ratioH), height: currentBlockHeight, } - } - ctx.clearRect(0, 0, sourceWidth, sourceHeight) - - let resolve: () => void - const promise = new Promise(r => resolve = r) - - // start - let activeWorkers = 0 - for (let c = 0; c < cores; c++) { - if (partitions[c] === undefined) { - // no job for this worker - continue - } - - activeWorkers++ const worker = new Worker(this.workerBlobURL) + activeWorkers += 1 workers[c] = worker - worker.onmessage = (event: IWorkerResultMessage) => { - activeWorkers-- - const core = event.data.core - workers[core].terminate() - delete workers[core] + worker.terminate() + delete workers[c] + activeWorkers -= 1 + partition.target.data.set(event.data.target) + ctx.putImageData(partition.target, 0, partition.startY) - // draw - // const height_part = Math.ceil(partitions[core].height / ratioH) - // partitions[core].target = ctx.createImageData(width, height_part) - partitions[core].target.data.set(event.data.target) - ctx.putImageData(partitions[core].target, 0, partitions[core].startY) - - if (activeWorkers <= 0) { - resolve() + if (!activeWorkers) { + resolve(canvas) } } + + worker.onerror = (err: ErrorEvent) => { + workers.forEach(w => w.terminate()) + workers.length = 0 + reject(new Error('Error resizing: ' + err.message)) + } + const message: IWorkerParam = { sourceWidth, - sourceHeight: partitions[c].height, + sourceHeight: partition.height, width, - height: Math.ceil(partitions[c].height / ratioH), - core: c, - source: partitions[c].source.data.buffer, + height: Math.ceil(partition.height / ratioH), + source: partition.source.data.buffer, } worker.postMessage(message, [message.source]) } + canvas.width = width + canvas.height = height + // ctx.clearRect(0, 0, sourceWidth, sourceHeight) + return promise } }