diff --git a/package-lock.json b/package-lock.json index f438291..75ee7e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1519,6 +1519,9 @@ "@rondo/common": { "version": "file:packages/common" }, + "@rondo/image-upload": { + "version": "file:packages/image-upload" + }, "@rondo/server": { "version": "file:packages/server", "requires": { diff --git a/package.json b/package.json index 22ed730..cbfacf9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "@rondo/server": "file:packages/server", "@rondo/comments-server": "file:packages/comments-server", "@rondo/comments-common": "file:packages/comments-common", - "@rondo/comments-client": "file:packages/comments-client" + "@rondo/comments-client": "file:packages/comments-client", + "@rondo/image-upload": "file:packages/image-upload" }, "devDependencies": { "@types/bcrypt": "^3.0.0", diff --git a/packages/image-upload/Buildfile b/packages/image-upload/Buildfile new file mode 100644 index 0000000..40aaa22 --- /dev/null +++ b/packages/image-upload/Buildfile @@ -0,0 +1,8 @@ +build: + tsc + +esm: + tsc -p tsconfig.esm.json + +clean: + rm -rf lib/ diff --git a/packages/image-upload/jest.config.js b/packages/image-upload/jest.config.js new file mode 100644 index 0000000..737dab0 --- /dev/null +++ b/packages/image-upload/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + roots: [ + '/src' + ], + transform: { + '^.+\\.tsx?$': 'ts-jest' + }, + testRegex: '(/__tests__/.*|\\.(test|spec))\\.tsx?$', + moduleFileExtensions: [ + 'ts', + 'tsx', + 'js', + 'jsx' + ], + setupFiles: ['/jest.setup.js'] +} diff --git a/packages/image-upload/jest.setup.js b/packages/image-upload/jest.setup.js new file mode 100644 index 0000000..a952c9b --- /dev/null +++ b/packages/image-upload/jest.setup.js @@ -0,0 +1,4 @@ +if (!process.env.LOG) { + process.env.LOG = 'sql:warn' +} +process.chdir(__dirname) diff --git a/packages/image-upload/package.json b/packages/image-upload/package.json new file mode 100644 index 0000000..914d248 --- /dev/null +++ b/packages/image-upload/package.json @@ -0,0 +1,11 @@ +{ + "name": "@rondo/image-upload", + "private": true, + "peerDependencies": { + "react": "^16.7.0", + "react-dom": "^16.7.0" + }, + "main": "lib/index.js", + "types": "lib/index.d.ts", + "module": "esm/index.js" +} diff --git a/packages/image-upload/src/ImageUpload.tsx b/packages/image-upload/src/ImageUpload.tsx new file mode 100644 index 0000000..5ca7306 --- /dev/null +++ b/packages/image-upload/src/ImageUpload.tsx @@ -0,0 +1,45 @@ +import React from 'react' + +export interface IImage { + base64: string +} + +export interface IImageUploadProps { + onChange: (images: IImage[]) => void + multiple: boolean +} + +export class ImageUpload extends React.PureComponent { + fileInput: React.RefObject + constructor(props: IImageUploadProps) { + super(props) + 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) + }) + } + render() { + return ( +
+ +
+ ) + } +} diff --git a/packages/image-upload/src/Resizer.ts b/packages/image-upload/src/Resizer.ts new file mode 100644 index 0000000..d67d05f --- /dev/null +++ b/packages/image-upload/src/Resizer.ts @@ -0,0 +1,208 @@ +interface IPartition { + source: ImageData + target: ImageData + startY: number + height: number +} + +interface IWorkerParam { + sourceWidth: number + sourceHeight: number + width: number + height: number + core: number + source: ArrayBuffer +} + +interface IWorkerParamMessage extends MessageEvent { + data: IWorkerParam +} + +interface IWorkerResult { + core: number + target: Uint8ClampedArray +} + +interface IWorkerResultMessage extends MessageEvent { + data: IWorkerResult +} + +function ResizeWorker() { + function onmessage(event: IWorkerParamMessage) { + const core = event.data.core + const sourceWidth = event.data.sourceWidth + const sourceHeight = event.data.sourceHeight + const width = event.data.width + const height = event.data.height + + const ratioW = sourceWidth / width + const ratioH = sourceHeight / height + const ratioHalfW = Math.ceil(ratioW / 2) + const ratioHalfH = Math.ceil(ratioH / 2) + + const source = new Uint8ClampedArray(event.data.source) + const sourceH = source.length / sourceWidth / 4 + const targetSize = width * height * 4 + const targetMemory = new ArrayBuffer(targetSize) + const target = new Uint8ClampedArray(targetMemory, 0, targetSize) + // calculate + for (let j = 0; j < height; j++) { + for (let i = 0; i < width; i++) { + const x2 = (i + j * width) * 4 + let weight = 0 + let weights = 0 + let weightsAlpha = 0 + let gxR = 0 + let gxG = 0 + let gxB = 0 + let gxA = 0 + const centerY = j * ratioH + + const xxStart = Math.floor(i * ratioW) + let xxStop = Math.ceil((i + 1) * ratioW) + const yyStart = Math.floor(j * ratioH) + let yyStop = Math.ceil((j + 1) * ratioH) + + xxStop = Math.min(xxStop, sourceWidth) + yyStop = Math.min(yyStop, sourceHeight) + + for (let yy = yyStart; yy < yyStop; yy++) { + const dy = Math.abs(centerY - yy) / ratioHalfH + const centerX = i * ratioW + const w0 = dy * dy // pre-calc part of w + for (let xx = xxStart; xx < xxStop; xx++) { + const dx = Math.abs(centerX - xx) / ratioHalfW + const w = Math.sqrt(w0 + dx * dx) + if (w >= 1) { + // pixel too far + continue + } + // hermite filter + weight = 2 * w * w * w - 3 * w * w + 1 + // calc source pixel location + const posX = 4 * (xx + yy * sourceWidth) + // alpha + gxA += weight * source[posX + 3] + weightsAlpha += weight + // colors + if (source[posX + 3] < 255) { + weight = weight * source[posX + 3] / 250 + } + gxR += weight * source[posX] + gxG += weight * source[posX + 1] + gxB += weight * source[posX + 2] + weights += weight + } + } + target[x2] = gxR / weights + target[x2 + 1] = gxG / weights + target[x2 + 2] = gxB / weights + target[x2 + 3] = gxA / weightsAlpha + } + } + + const objData: IWorkerResult = { + core, + target, + } + postMessage(objData, [target.buffer] as any) + } +} + +export class Resizer { + readonly cores = navigator.hardwareConcurrency || 4 + readonly workerBlobURL = window.URL.createObjectURL( + new Blob( + ['(', ResizeWorker.toString(), ')()'], + {type: 'application/javascript'}, + )) + + async resample(canvas: HTMLCanvasElement, width: number, height: number) { + const {cores} = this + const sourceWidth = canvas.width + const sourceHeight = canvas.height + width = Math.round(width) + 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) + const blockHeight = Math.ceil(sourceHeight / cores / 2) * 2 + let endY = -1 + 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) + + let currentBlockHeight = blockHeight + currentBlockHeight = Math.min(blockHeight, sourceHeight - offsetY) + + // console.log( + // 'source split: ', '#'+c, offsetY, endY, 'height: '+currentBlockHeight); + + partitions[c] = { + 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) + workers[c] = worker + + worker.onmessage = (event: IWorkerResultMessage) => { + activeWorkers-- + const core = event.data.core + workers[core].terminate() + delete workers[core] + + // 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() + } + } + const message: IWorkerParam = { + sourceWidth, + sourceHeight: partitions[c].height, + width, + height: Math.ceil(partitions[c].height / ratioH), + core: c, + source: partitions[c].source.data.buffer, + } + worker.postMessage(message, [message.source]) + } + + return promise + } +} diff --git a/packages/image-upload/src/index.ts b/packages/image-upload/src/index.ts new file mode 100644 index 0000000..c88df17 --- /dev/null +++ b/packages/image-upload/src/index.ts @@ -0,0 +1 @@ +export * from './ImageUpload' diff --git a/packages/image-upload/tsconfig.esm.json b/packages/image-upload/tsconfig.esm.json new file mode 100644 index 0000000..915284d --- /dev/null +++ b/packages/image-upload/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "esm" + }, + "references": [] +} diff --git a/packages/image-upload/tsconfig.json b/packages/image-upload/tsconfig.json new file mode 100644 index 0000000..3b72bf2 --- /dev/null +++ b/packages/image-upload/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.common.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "references": [] +} diff --git a/packages/image-upload/tslint.json b/packages/image-upload/tslint.json new file mode 100644 index 0000000..aa5f8cd --- /dev/null +++ b/packages/image-upload/tslint.json @@ -0,0 +1,5 @@ +{ + "extends": [ + "../tslint.json" + ] +}