Add ability to resize images client-side
This commit is contained in:
parent
7de6157129
commit
355d8c8e51
30
packages/image-upload/src/Files.ts
Normal file
30
packages/image-upload/src/Files.ts
Normal file
@ -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<string> {
|
||||||
|
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<ArrayBuffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(reader.result as ArrayBuffer)
|
||||||
|
}
|
||||||
|
attachErrorListener(reader, file, reject)
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
39
packages/image-upload/src/Image.ts
Normal file
39
packages/image-upload/src/Image.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export async function createImageFromDataURL(dataURL: string)
|
||||||
|
: Promise<HTMLImageElement> {
|
||||||
|
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<HTMLCanvasElement> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import {Resizer} from './Resizer'
|
||||||
|
import {readAsDataURL} from './Files'
|
||||||
|
import {drawCanvasFromDataURL} from './Image'
|
||||||
|
|
||||||
export interface IImage {
|
export interface IImage {
|
||||||
base64: string
|
dataURL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IImageUploadProps {
|
export interface IImageUploadProps {
|
||||||
@ -16,23 +19,55 @@ export class ImageUpload extends React.PureComponent<IImageUploadProps> {
|
|||||||
this.fileInput = React.createRef()
|
this.fileInput = React.createRef()
|
||||||
}
|
}
|
||||||
|
|
||||||
handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
safeHandleChange = async (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||||
const files = this.fileInput.current!.files!
|
try {
|
||||||
|
await this.handleChange(event)
|
||||||
Array.prototype.forEach.call(files, (file: File) => {
|
} catch (err) {
|
||||||
const reader = new FileReader()
|
console.log('Error in handleChange', err)
|
||||||
reader.addEventListener('load', function() {
|
|
||||||
const {result} = this
|
|
||||||
console.log(file.name, result)
|
|
||||||
// TODO resize image...
|
|
||||||
})
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
handleChange = async (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||||
|
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<string> {
|
||||||
|
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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className='image-upload'>
|
<div className='image-upload'>
|
||||||
<input
|
<input
|
||||||
|
autoComplete='off'
|
||||||
multiple={this.props.multiple}
|
multiple={this.props.multiple}
|
||||||
type='file'
|
type='file'
|
||||||
accept='image/*'
|
accept='image/*'
|
||||||
|
|||||||
@ -1,35 +1,26 @@
|
|||||||
interface IPartition {
|
|
||||||
source: ImageData
|
|
||||||
target: ImageData
|
|
||||||
startY: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IWorkerParam {
|
interface IWorkerParam {
|
||||||
sourceWidth: number
|
sourceWidth: number
|
||||||
sourceHeight: number
|
sourceHeight: number
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
core: number
|
|
||||||
source: ArrayBuffer
|
source: ArrayBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IWorkerParamMessage extends MessageEvent {
|
interface IWorkerParamMessage {
|
||||||
data: IWorkerParam
|
data: IWorkerParam
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IWorkerResult {
|
interface IWorkerResult {
|
||||||
core: number
|
|
||||||
target: Uint8ClampedArray
|
target: Uint8ClampedArray
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IWorkerResultMessage extends MessageEvent {
|
interface IWorkerResultMessage {
|
||||||
data: IWorkerResult
|
data: IWorkerResult
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResizeWorker() {
|
function createResizeWorker(root: any = {})
|
||||||
function onmessage(event: IWorkerParamMessage) {
|
: {onmessage: (event: IWorkerParamMessage) => void} {
|
||||||
const core = event.data.core
|
root.onmessage = (event: IWorkerParamMessage) => {
|
||||||
const sourceWidth = event.data.sourceWidth
|
const sourceWidth = event.data.sourceWidth
|
||||||
const sourceHeight = event.data.sourceHeight
|
const sourceHeight = event.data.sourceHeight
|
||||||
const width = event.data.width
|
const width = event.data.width
|
||||||
@ -102,22 +93,26 @@ function ResizeWorker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const objData: IWorkerResult = {
|
const objData: IWorkerResult = {
|
||||||
core,
|
|
||||||
target,
|
target,
|
||||||
}
|
}
|
||||||
postMessage(objData, [target.buffer] as any)
|
postMessage(objData, [target.buffer] as any)
|
||||||
}
|
}
|
||||||
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Resizer {
|
export class Resizer {
|
||||||
readonly cores = navigator.hardwareConcurrency || 4
|
readonly cores = navigator.hardwareConcurrency || 4
|
||||||
readonly workerBlobURL = window.URL.createObjectURL(
|
readonly workerBlobURL = window.URL.createObjectURL(
|
||||||
new Blob(
|
new Blob(
|
||||||
['(', ResizeWorker.toString(), ')()'],
|
['(', createResizeWorker.toString(), ')(self)'],
|
||||||
{type: 'application/javascript'},
|
{type: 'application/javascript'},
|
||||||
))
|
))
|
||||||
|
|
||||||
async resample(canvas: HTMLCanvasElement, width: number, height: number) {
|
async resample(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Promise<HTMLCanvasElement> {
|
||||||
const {cores} = this
|
const {cores} = this
|
||||||
const sourceWidth = canvas.width
|
const sourceWidth = canvas.width
|
||||||
const sourceHeight = canvas.height
|
const sourceHeight = canvas.height
|
||||||
@ -125,84 +120,74 @@ export class Resizer {
|
|||||||
height = Math.round(height)
|
height = Math.round(height)
|
||||||
const ratioH = sourceHeight / height
|
const ratioH = sourceHeight / height
|
||||||
|
|
||||||
const workers = new Array(cores)
|
|
||||||
// TODO handle null
|
// TODO handle null
|
||||||
const ctx = canvas.getContext('2d')!
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
// prepare source and target data for workers
|
let resolve: (canvas: HTMLCanvasElement) => void
|
||||||
const partitions: IPartition[] = new Array(cores)
|
let reject: (err: Error) => void
|
||||||
|
const promise = new Promise<HTMLCanvasElement>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
|
||||||
const blockHeight = Math.ceil(sourceHeight / cores / 2) * 2
|
const blockHeight = Math.ceil(sourceHeight / cores / 2) * 2
|
||||||
let endY = -1
|
let endY = -1
|
||||||
|
let activeWorkers = 0
|
||||||
|
const workers: Worker[] = []
|
||||||
for (let c = 0; c < cores; c++) {
|
for (let c = 0; c < cores; c++) {
|
||||||
// source
|
|
||||||
const offsetY = endY + 1
|
const offsetY = endY + 1
|
||||||
if (offsetY >= sourceHeight) {
|
if (offsetY >= sourceHeight) {
|
||||||
// size too small, nothing left for this core
|
// size too small, nothing left for this core
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
endY = offsetY + blockHeight - 1
|
endY = Math.min(offsetY + blockHeight - 1, sourceHeight - 1)
|
||||||
endY = Math.min(endY, sourceHeight - 1)
|
|
||||||
|
|
||||||
let currentBlockHeight = blockHeight
|
const currentBlockHeight = Math.min(blockHeight, sourceHeight - offsetY)
|
||||||
currentBlockHeight = Math.min(blockHeight, sourceHeight - offsetY)
|
|
||||||
|
|
||||||
// console.log(
|
const partition = {
|
||||||
// 'source split: ', '#'+c, offsetY, endY, 'height: '+currentBlockHeight);
|
|
||||||
|
|
||||||
partitions[c] = {
|
|
||||||
source: ctx.getImageData(0, offsetY, sourceWidth, blockHeight),
|
source: ctx.getImageData(0, offsetY, sourceWidth, blockHeight),
|
||||||
target: ctx.createImageData(
|
target: ctx.createImageData(
|
||||||
width, Math.ceil(currentBlockHeight / ratioH)),
|
width, Math.ceil(currentBlockHeight / ratioH)),
|
||||||
startY: Math.ceil(offsetY / ratioH),
|
startY: Math.ceil(offsetY / ratioH),
|
||||||
height: currentBlockHeight,
|
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)
|
const worker = new Worker(this.workerBlobURL)
|
||||||
|
activeWorkers += 1
|
||||||
workers[c] = worker
|
workers[c] = worker
|
||||||
|
|
||||||
worker.onmessage = (event: IWorkerResultMessage) => {
|
worker.onmessage = (event: IWorkerResultMessage) => {
|
||||||
activeWorkers--
|
worker.terminate()
|
||||||
const core = event.data.core
|
delete workers[c]
|
||||||
workers[core].terminate()
|
activeWorkers -= 1
|
||||||
delete workers[core]
|
partition.target.data.set(event.data.target)
|
||||||
|
ctx.putImageData(partition.target, 0, partition.startY)
|
||||||
|
|
||||||
// draw
|
if (!activeWorkers) {
|
||||||
// const height_part = Math.ceil(partitions[core].height / ratioH)
|
resolve(canvas)
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
worker.onerror = (err: ErrorEvent) => {
|
||||||
|
workers.forEach(w => w.terminate())
|
||||||
|
workers.length = 0
|
||||||
|
reject(new Error('Error resizing: ' + err.message))
|
||||||
|
}
|
||||||
|
|
||||||
const message: IWorkerParam = {
|
const message: IWorkerParam = {
|
||||||
sourceWidth,
|
sourceWidth,
|
||||||
sourceHeight: partitions[c].height,
|
sourceHeight: partition.height,
|
||||||
width,
|
width,
|
||||||
height: Math.ceil(partitions[c].height / ratioH),
|
height: Math.ceil(partition.height / ratioH),
|
||||||
core: c,
|
source: partition.source.data.buffer,
|
||||||
source: partitions[c].source.data.buffer,
|
|
||||||
}
|
}
|
||||||
worker.postMessage(message, [message.source])
|
worker.postMessage(message, [message.source])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
// ctx.clearRect(0, 0, sourceWidth, sourceHeight)
|
||||||
|
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user