UNPKG

filepond-plugin-image-preview

Version:
1,820 lines (1,536 loc) 53.5 kB
/*! * FilePondPluginImagePreview 4.6.12 * Licensed under MIT, https://opensource.org/licenses/MIT/ * Please visit https://pqina.nl/filepond/ for details. */ /* eslint-disable */ // test if file is of type image and can be viewed in canvas const isPreviewableImage = file => /^image/.test(file.type); const vectorMultiply = (v, amount) => createVector(v.x * amount, v.y * amount); const vectorAdd = (a, b) => createVector(a.x + b.x, a.y + b.y); const vectorNormalize = v => { const l = Math.sqrt(v.x * v.x + v.y * v.y); if (l === 0) { return { x: 0, y: 0 }; } return createVector(v.x / l, v.y / l); }; const vectorRotate = (v, radians, origin) => { const cos = Math.cos(radians); const sin = Math.sin(radians); const t = createVector(v.x - origin.x, v.y - origin.y); return createVector( origin.x + cos * t.x - sin * t.y, origin.y + sin * t.x + cos * t.y ); }; const createVector = (x = 0, y = 0) => ({ x, y }); const getMarkupValue = (value, size, scalar = 1, axis) => { if (typeof value === 'string') { return parseFloat(value) * scalar; } if (typeof value === 'number') { return value * (axis ? size[axis] : Math.min(size.width, size.height)); } return; }; const getMarkupStyles = (markup, size, scale) => { const lineStyle = markup.borderStyle || markup.lineStyle || 'solid'; const fill = markup.backgroundColor || markup.fontColor || 'transparent'; const stroke = markup.borderColor || markup.lineColor || 'transparent'; const strokeWidth = getMarkupValue( markup.borderWidth || markup.lineWidth, size, scale ); const lineCap = markup.lineCap || 'round'; const lineJoin = markup.lineJoin || 'round'; const dashes = typeof lineStyle === 'string' ? '' : lineStyle.map(v => getMarkupValue(v, size, scale)).join(','); const opacity = markup.opacity || 1; return { 'stroke-linecap': lineCap, 'stroke-linejoin': lineJoin, 'stroke-width': strokeWidth || 0, 'stroke-dasharray': dashes, stroke, fill, opacity }; }; const isDefined = value => value != null; const getMarkupRect = (rect, size, scalar = 1) => { let left = getMarkupValue(rect.x, size, scalar, 'width') || getMarkupValue(rect.left, size, scalar, 'width'); let top = getMarkupValue(rect.y, size, scalar, 'height') || getMarkupValue(rect.top, size, scalar, 'height'); let width = getMarkupValue(rect.width, size, scalar, 'width'); let height = getMarkupValue(rect.height, size, scalar, 'height'); let right = getMarkupValue(rect.right, size, scalar, 'width'); let bottom = getMarkupValue(rect.bottom, size, scalar, 'height'); if (!isDefined(top)) { if (isDefined(height) && isDefined(bottom)) { top = size.height - height - bottom; } else { top = bottom; } } if (!isDefined(left)) { if (isDefined(width) && isDefined(right)) { left = size.width - width - right; } else { left = right; } } if (!isDefined(width)) { if (isDefined(left) && isDefined(right)) { width = size.width - left - right; } else { width = 0; } } if (!isDefined(height)) { if (isDefined(top) && isDefined(bottom)) { height = size.height - top - bottom; } else { height = 0; } } return { x: left || 0, y: top || 0, width: width || 0, height: height || 0 }; }; const pointsToPathShape = points => points .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) .join(' '); const setAttributes = (element, attr) => Object.keys(attr).forEach(key => element.setAttribute(key, attr[key])); const ns = 'http://www.w3.org/2000/svg'; const svg = (tag, attr) => { const element = document.createElementNS(ns, tag); if (attr) { setAttributes(element, attr); } return element; }; const updateRect = element => setAttributes(element, { ...element.rect, ...element.styles }); const updateEllipse = element => { const cx = element.rect.x + element.rect.width * 0.5; const cy = element.rect.y + element.rect.height * 0.5; const rx = element.rect.width * 0.5; const ry = element.rect.height * 0.5; return setAttributes(element, { cx, cy, rx, ry, ...element.styles }); }; const IMAGE_FIT_STYLE = { contain: 'xMidYMid meet', cover: 'xMidYMid slice' }; const updateImage = (element, markup) => { setAttributes(element, { ...element.rect, ...element.styles, preserveAspectRatio: IMAGE_FIT_STYLE[markup.fit] || 'none' }); }; const TEXT_ANCHOR = { left: 'start', center: 'middle', right: 'end' }; const updateText = (element, markup, size, scale) => { const fontSize = getMarkupValue(markup.fontSize, size, scale); const fontFamily = markup.fontFamily || 'sans-serif'; const fontWeight = markup.fontWeight || 'normal'; const textAlign = TEXT_ANCHOR[markup.textAlign] || 'start'; setAttributes(element, { ...element.rect, ...element.styles, 'stroke-width': 0, 'font-weight': fontWeight, 'font-size': fontSize, 'font-family': fontFamily, 'text-anchor': textAlign }); // update text if (element.text !== markup.text) { element.text = markup.text; element.textContent = markup.text.length ? markup.text : ' '; } }; const updateLine = (element, markup, size, scale) => { setAttributes(element, { ...element.rect, ...element.styles, fill: 'none' }); const line = element.childNodes[0]; const begin = element.childNodes[1]; const end = element.childNodes[2]; const origin = element.rect; const target = { x: element.rect.x + element.rect.width, y: element.rect.y + element.rect.height }; setAttributes(line, { x1: origin.x, y1: origin.y, x2: target.x, y2: target.y }); if (!markup.lineDecoration) return; begin.style.display = 'none'; end.style.display = 'none'; const v = vectorNormalize({ x: target.x - origin.x, y: target.y - origin.y }); const l = getMarkupValue(0.05, size, scale); if (markup.lineDecoration.indexOf('arrow-begin') !== -1) { const arrowBeginRotationPoint = vectorMultiply(v, l); const arrowBeginCenter = vectorAdd(origin, arrowBeginRotationPoint); const arrowBeginA = vectorRotate(origin, 2, arrowBeginCenter); const arrowBeginB = vectorRotate(origin, -2, arrowBeginCenter); setAttributes(begin, { style: 'display:block;', d: `M${arrowBeginA.x},${arrowBeginA.y} L${origin.x},${origin.y} L${ arrowBeginB.x },${arrowBeginB.y}` }); } if (markup.lineDecoration.indexOf('arrow-end') !== -1) { const arrowEndRotationPoint = vectorMultiply(v, -l); const arrowEndCenter = vectorAdd(target, arrowEndRotationPoint); const arrowEndA = vectorRotate(target, 2, arrowEndCenter); const arrowEndB = vectorRotate(target, -2, arrowEndCenter); setAttributes(end, { style: 'display:block;', d: `M${arrowEndA.x},${arrowEndA.y} L${target.x},${target.y} L${ arrowEndB.x },${arrowEndB.y}` }); } }; const updatePath = (element, markup, size, scale) => { setAttributes(element, { ...element.styles, fill: 'none', d: pointsToPathShape( markup.points.map(point => ({ x: getMarkupValue(point.x, size, scale, 'width'), y: getMarkupValue(point.y, size, scale, 'height') })) ) }); }; const createShape = node => markup => svg(node, { id: markup.id }); const createImage = markup => { const shape = svg('image', { id: markup.id, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', opacity: '0' }); shape.onload = () => { shape.setAttribute('opacity', markup.opacity || 1); }; shape.setAttributeNS( 'http://www.w3.org/1999/xlink', 'xlink:href', markup.src ); return shape; }; const createLine = markup => { const shape = svg('g', { id: markup.id, 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }); const line = svg('line'); shape.appendChild(line); const begin = svg('path'); shape.appendChild(begin); const end = svg('path'); shape.appendChild(end); return shape; }; const CREATE_TYPE_ROUTES = { image: createImage, rect: createShape('rect'), ellipse: createShape('ellipse'), text: createShape('text'), path: createShape('path'), line: createLine }; const UPDATE_TYPE_ROUTES = { rect: updateRect, ellipse: updateEllipse, image: updateImage, text: updateText, path: updatePath, line: updateLine }; const createMarkupByType = (type, markup) => CREATE_TYPE_ROUTES[type](markup); const updateMarkupByType = (element, type, markup, size, scale) => { if (type !== 'path') { element.rect = getMarkupRect(markup, size, scale); } element.styles = getMarkupStyles(markup, size, scale); UPDATE_TYPE_ROUTES[type](element, markup, size, scale); }; const MARKUP_RECT = [ 'x', 'y', 'left', 'top', 'right', 'bottom', 'width', 'height' ]; const toOptionalFraction = value => typeof value === 'string' && /%/.test(value) ? parseFloat(value) / 100 : value; // adds default markup properties, clones markup const prepareMarkup = markup => { const [type, props] = markup; const rect = props.points ? {} : MARKUP_RECT.reduce((prev, curr) => { prev[curr] = toOptionalFraction(props[curr]); return prev; }, {}); return [ type, { zIndex: 0, ...props, ...rect } ]; }; const sortMarkupByZIndex = (a, b) => { if (a[1].zIndex > b[1].zIndex) { return 1; } if (a[1].zIndex < b[1].zIndex) { return -1; } return 0; }; const createMarkupView = _ => _.utils.createView({ name: 'image-preview-markup', tag: 'svg', ignoreRect: true, mixins: { apis: ['width', 'height', 'crop', 'markup', 'resize', 'dirty'] }, write: ({ root, props }) => { if (!props.dirty) return; const { crop, resize, markup } = props; const viewWidth = props.width; const viewHeight = props.height; let cropWidth = crop.width; let cropHeight = crop.height; if (resize) { const { size } = resize; let outputWidth = size && size.width; let outputHeight = size && size.height; const outputFit = resize.mode; const outputUpscale = resize.upscale; if (outputWidth && !outputHeight) outputHeight = outputWidth; if (outputHeight && !outputWidth) outputWidth = outputHeight; const shouldUpscale = cropWidth < outputWidth && cropHeight < outputHeight; if (!shouldUpscale || (shouldUpscale && outputUpscale)) { let scalarWidth = outputWidth / cropWidth; let scalarHeight = outputHeight / cropHeight; if (outputFit === 'force') { cropWidth = outputWidth; cropHeight = outputHeight; } else { let scalar; if (outputFit === 'cover') { scalar = Math.max(scalarWidth, scalarHeight); } else if (outputFit === 'contain') { scalar = Math.min(scalarWidth, scalarHeight); } cropWidth = cropWidth * scalar; cropHeight = cropHeight * scalar; } } } const size = { width: viewWidth, height: viewHeight }; root.element.setAttribute('width', size.width); root.element.setAttribute('height', size.height); const scale = Math.min(viewWidth / cropWidth, viewHeight / cropHeight); // clear root.element.innerHTML = ''; // get filter const markupFilter = root.query('GET_IMAGE_PREVIEW_MARKUP_FILTER'); // draw new markup .filter(markupFilter) .map(prepareMarkup) .sort(sortMarkupByZIndex) .forEach(markup => { const [type, settings] = markup; // create const element = createMarkupByType(type, settings); // update updateMarkupByType(element, type, settings, size, scale); // add root.element.appendChild(element); }); } }); const createVector$1 = (x, y) => ({ x, y }); const vectorDot = (a, b) => a.x * b.x + a.y * b.y; const vectorSubtract = (a, b) => createVector$1(a.x - b.x, a.y - b.y); const vectorDistanceSquared = (a, b) => vectorDot(vectorSubtract(a, b), vectorSubtract(a, b)); const vectorDistance = (a, b) => Math.sqrt(vectorDistanceSquared(a, b)); const getOffsetPointOnEdge = (length, rotation) => { const a = length; const A = 1.5707963267948966; const B = rotation; const C = 1.5707963267948966 - rotation; const sinA = Math.sin(A); const sinB = Math.sin(B); const sinC = Math.sin(C); const cosC = Math.cos(C); const ratio = a / sinA; const b = ratio * sinB; const c = ratio * sinC; return createVector$1(cosC * b, cosC * c); }; const getRotatedRectSize = (rect, rotation) => { const w = rect.width; const h = rect.height; const hor = getOffsetPointOnEdge(w, rotation); const ver = getOffsetPointOnEdge(h, rotation); const tl = createVector$1(rect.x + Math.abs(hor.x), rect.y - Math.abs(hor.y)); const tr = createVector$1( rect.x + rect.width + Math.abs(ver.y), rect.y + Math.abs(ver.x) ); const bl = createVector$1( rect.x - Math.abs(ver.y), rect.y + rect.height - Math.abs(ver.x) ); return { width: vectorDistance(tl, tr), height: vectorDistance(tl, bl) }; }; const calculateCanvasSize = (image, canvasAspectRatio, zoom = 1) => { const imageAspectRatio = image.height / image.width; // determine actual pixels on x and y axis let canvasWidth = 1; let canvasHeight = canvasAspectRatio; let imgWidth = 1; let imgHeight = imageAspectRatio; if (imgHeight > canvasHeight) { imgHeight = canvasHeight; imgWidth = imgHeight / imageAspectRatio; } const scalar = Math.max(canvasWidth / imgWidth, canvasHeight / imgHeight); const width = image.width / (zoom * scalar * imgWidth); const height = width * canvasAspectRatio; return { width: width, height: height }; }; const getImageRectZoomFactor = (imageRect, cropRect, rotation, center) => { // calculate available space round image center position const cx = center.x > 0.5 ? 1 - center.x : center.x; const cy = center.y > 0.5 ? 1 - center.y : center.y; const imageWidth = cx * 2 * imageRect.width; const imageHeight = cy * 2 * imageRect.height; // calculate rotated crop rectangle size const rotatedCropSize = getRotatedRectSize(cropRect, rotation); // calculate scalar required to fit image return Math.max( rotatedCropSize.width / imageWidth, rotatedCropSize.height / imageHeight ); }; const getCenteredCropRect = (container, aspectRatio) => { let width = container.width; let height = width * aspectRatio; if (height > container.height) { height = container.height; width = height / aspectRatio; } const x = (container.width - width) * 0.5; const y = (container.height - height) * 0.5; return { x, y, width, height }; }; const getCurrentCropSize = (imageSize, crop = {}) => { let { zoom, rotation, center, aspectRatio } = crop; if (!aspectRatio) aspectRatio = imageSize.height / imageSize.width; const canvasSize = calculateCanvasSize(imageSize, aspectRatio, zoom); const canvasCenter = { x: canvasSize.width * 0.5, y: canvasSize.height * 0.5 }; const stage = { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height, center: canvasCenter }; const shouldLimit = typeof crop.scaleToFit === 'undefined' || crop.scaleToFit; const stageZoomFactor = getImageRectZoomFactor( imageSize, getCenteredCropRect(stage, aspectRatio), rotation, shouldLimit ? center : { x: 0.5, y: 0.5 } ); const scale = zoom * stageZoomFactor; // start drawing return { widthFloat: canvasSize.width / scale, heightFloat: canvasSize.height / scale, width: Math.round(canvasSize.width / scale), height: Math.round(canvasSize.height / scale) }; }; const IMAGE_SCALE_SPRING_PROPS = { type: 'spring', stiffness: 0.5, damping: 0.45, mass: 10 }; // does horizontal and vertical flipping const createBitmapView = _ => _.utils.createView({ name: 'image-bitmap', ignoreRect: true, mixins: { styles: ['scaleX', 'scaleY'] }, create: ({ root, props }) => { root.appendChild(props.image); } }); // shifts and rotates image const createImageCanvasWrapper = _ => _.utils.createView({ name: 'image-canvas-wrapper', tag: 'div', ignoreRect: true, mixins: { apis: ['crop', 'width', 'height'], styles: [ 'originX', 'originY', 'translateX', 'translateY', 'scaleX', 'scaleY', 'rotateZ' ], animations: { originX: IMAGE_SCALE_SPRING_PROPS, originY: IMAGE_SCALE_SPRING_PROPS, scaleX: IMAGE_SCALE_SPRING_PROPS, scaleY: IMAGE_SCALE_SPRING_PROPS, translateX: IMAGE_SCALE_SPRING_PROPS, translateY: IMAGE_SCALE_SPRING_PROPS, rotateZ: IMAGE_SCALE_SPRING_PROPS } }, create: ({ root, props }) => { props.width = props.image.width; props.height = props.image.height; root.ref.bitmap = root.appendChildView( root.createChildView(createBitmapView(_), { image: props.image }) ); }, write: ({ root, props }) => { const { flip } = props.crop; const { bitmap } = root.ref; bitmap.scaleX = flip.horizontal ? -1 : 1; bitmap.scaleY = flip.vertical ? -1 : 1; } }); // clips canvas to correct aspect ratio const createClipView = _ => _.utils.createView({ name: 'image-clip', tag: 'div', ignoreRect: true, mixins: { apis: [ 'crop', 'markup', 'resize', 'width', 'height', 'dirty', 'background' ], styles: ['width', 'height', 'opacity'], animations: { opacity: { type: 'tween', duration: 250 } } }, didWriteView: function({ root, props }) { if (!props.background) return; root.element.style.backgroundColor = props.background; }, create: ({ root, props }) => { root.ref.image = root.appendChildView( root.createChildView( createImageCanvasWrapper(_), Object.assign({}, props) ) ); root.ref.createMarkup = () => { if (root.ref.markup) return; root.ref.markup = root.appendChildView( root.createChildView(createMarkupView(_), Object.assign({}, props)) ); }; root.ref.destroyMarkup = () => { if (!root.ref.markup) return; root.removeChildView(root.ref.markup); root.ref.markup = null; }; // set up transparency grid const transparencyIndicator = root.query( 'GET_IMAGE_PREVIEW_TRANSPARENCY_INDICATOR' ); if (transparencyIndicator === null) return; // grid pattern if (transparencyIndicator === 'grid') { root.element.dataset.transparencyIndicator = transparencyIndicator; } // basic color else { root.element.dataset.transparencyIndicator = 'color'; } }, write: ({ root, props, shouldOptimize }) => { const { crop, markup, resize, dirty, width, height } = props; root.ref.image.crop = crop; const stage = { x: 0, y: 0, width, height, center: { x: width * 0.5, y: height * 0.5 } }; const image = { width: root.ref.image.width, height: root.ref.image.height }; const origin = { x: crop.center.x * image.width, y: crop.center.y * image.height }; const translation = { x: stage.center.x - image.width * crop.center.x, y: stage.center.y - image.height * crop.center.y }; const rotation = Math.PI * 2 + (crop.rotation % (Math.PI * 2)); const cropAspectRatio = crop.aspectRatio || image.height / image.width; const shouldLimit = typeof crop.scaleToFit === 'undefined' || crop.scaleToFit; const stageZoomFactor = getImageRectZoomFactor( image, getCenteredCropRect(stage, cropAspectRatio), rotation, shouldLimit ? crop.center : { x: 0.5, y: 0.5 } ); const scale = crop.zoom * stageZoomFactor; // update markup view if (markup && markup.length) { root.ref.createMarkup(); root.ref.markup.width = width; root.ref.markup.height = height; root.ref.markup.resize = resize; root.ref.markup.dirty = dirty; root.ref.markup.markup = markup; root.ref.markup.crop = getCurrentCropSize(image, crop); } else if (root.ref.markup) { root.ref.destroyMarkup(); } // update image view const imageView = root.ref.image; // don't update clip layout if (shouldOptimize) { imageView.originX = null; imageView.originY = null; imageView.translateX = null; imageView.translateY = null; imageView.rotateZ = null; imageView.scaleX = null; imageView.scaleY = null; return; } imageView.originX = origin.x; imageView.originY = origin.y; imageView.translateX = translation.x; imageView.translateY = translation.y; imageView.rotateZ = rotation; imageView.scaleX = scale; imageView.scaleY = scale; } }); const createImageView = _ => _.utils.createView({ name: 'image-preview', tag: 'div', ignoreRect: true, mixins: { apis: ['image', 'crop', 'markup', 'resize', 'dirty', 'background'], styles: ['translateY', 'scaleX', 'scaleY', 'opacity'], animations: { scaleX: IMAGE_SCALE_SPRING_PROPS, scaleY: IMAGE_SCALE_SPRING_PROPS, translateY: IMAGE_SCALE_SPRING_PROPS, opacity: { type: 'tween', duration: 400 } } }, create: ({ root, props }) => { root.ref.clip = root.appendChildView( root.createChildView(createClipView(_), { id: props.id, image: props.image, crop: props.crop, markup: props.markup, resize: props.resize, dirty: props.dirty, background: props.background }) ); }, write: ({ root, props, shouldOptimize }) => { const { clip } = root.ref; const { image, crop, markup, resize, dirty } = props; clip.crop = crop; clip.markup = markup; clip.resize = resize; clip.dirty = dirty; // don't update clip layout clip.opacity = shouldOptimize ? 0 : 1; // don't re-render if optimizing or hidden (width will be zero resulting in weird animations) if (shouldOptimize || root.rect.element.hidden) return; // calculate scaled preview image size const imageAspectRatio = image.height / image.width; let aspectRatio = crop.aspectRatio || imageAspectRatio; // calculate container size const containerWidth = root.rect.inner.width; const containerHeight = root.rect.inner.height; let fixedPreviewHeight = root.query('GET_IMAGE_PREVIEW_HEIGHT'); const minPreviewHeight = root.query('GET_IMAGE_PREVIEW_MIN_HEIGHT'); const maxPreviewHeight = root.query('GET_IMAGE_PREVIEW_MAX_HEIGHT'); const panelAspectRatio = root.query('GET_PANEL_ASPECT_RATIO'); const allowMultiple = root.query('GET_ALLOW_MULTIPLE'); if (panelAspectRatio && !allowMultiple) { fixedPreviewHeight = containerWidth * panelAspectRatio; aspectRatio = panelAspectRatio; } // determine clip width and height let clipHeight = fixedPreviewHeight !== null ? fixedPreviewHeight : Math.max( minPreviewHeight, Math.min(containerWidth * aspectRatio, maxPreviewHeight) ); let clipWidth = clipHeight / aspectRatio; if (clipWidth > containerWidth) { clipWidth = containerWidth; clipHeight = clipWidth * aspectRatio; } if (clipHeight > containerHeight) { clipHeight = containerHeight; clipWidth = containerHeight / aspectRatio; } clip.width = clipWidth; clip.height = clipHeight; } }); let SVG_MASK = `<svg width="500" height="200" viewBox="0 0 500 200" preserveAspectRatio="none"> <defs> <radialGradient id="gradient-__UID__" cx=".5" cy="1.25" r="1.15"> <stop offset='50%' stop-color='#000000'/> <stop offset='56%' stop-color='#0a0a0a'/> <stop offset='63%' stop-color='#262626'/> <stop offset='69%' stop-color='#4f4f4f'/> <stop offset='75%' stop-color='#808080'/> <stop offset='81%' stop-color='#b1b1b1'/> <stop offset='88%' stop-color='#dadada'/> <stop offset='94%' stop-color='#f6f6f6'/> <stop offset='100%' stop-color='#ffffff'/> </radialGradient> <mask id="mask-__UID__"> <rect x="0" y="0" width="500" height="200" fill="url(#gradient-__UID__)"></rect> </mask> </defs> <rect x="0" width="500" height="200" fill="currentColor" mask="url(#mask-__UID__)"></rect> </svg>`; let SVGMaskUniqueId = 0; const createImageOverlayView = fpAPI => fpAPI.utils.createView({ name: 'image-preview-overlay', tag: 'div', ignoreRect: true, create: ({ root, props }) => { let mask = SVG_MASK; if (document.querySelector('base')) { const url = new URL( window.location.href.replace(window.location.hash, '') ).href; mask = mask.replace(/url\(\#/g, 'url(' + url + '#'); } SVGMaskUniqueId++; root.element.classList.add( `filepond--image-preview-overlay-${props.status}` ); root.element.innerHTML = mask.replace(/__UID__/g, SVGMaskUniqueId); }, mixins: { styles: ['opacity'], animations: { opacity: { type: 'spring', mass: 25 } } } }); /** * Bitmap Worker */ const BitmapWorker = function() { self.onmessage = e => { createImageBitmap(e.data.message.file).then(bitmap => { self.postMessage({ id: e.data.id, message: bitmap }, [bitmap]); }); }; }; /** * ColorMatrix Worker */ const ColorMatrixWorker = function() { self.onmessage = e => { const imageData = e.data.message.imageData; const matrix = e.data.message.colorMatrix; const data = imageData.data; const l = data.length; const m11 = matrix[0]; const m12 = matrix[1]; const m13 = matrix[2]; const m14 = matrix[3]; const m15 = matrix[4]; const m21 = matrix[5]; const m22 = matrix[6]; const m23 = matrix[7]; const m24 = matrix[8]; const m25 = matrix[9]; const m31 = matrix[10]; const m32 = matrix[11]; const m33 = matrix[12]; const m34 = matrix[13]; const m35 = matrix[14]; const m41 = matrix[15]; const m42 = matrix[16]; const m43 = matrix[17]; const m44 = matrix[18]; const m45 = matrix[19]; let index = 0, r = 0.0, g = 0.0, b = 0.0, a = 0.0; for (; index < l; index += 4) { r = data[index] / 255; g = data[index + 1] / 255; b = data[index + 2] / 255; a = data[index + 3] / 255; data[index] = Math.max( 0, Math.min((r * m11 + g * m12 + b * m13 + a * m14 + m15) * 255, 255) ); data[index + 1] = Math.max( 0, Math.min((r * m21 + g * m22 + b * m23 + a * m24 + m25) * 255, 255) ); data[index + 2] = Math.max( 0, Math.min((r * m31 + g * m32 + b * m33 + a * m34 + m35) * 255, 255) ); data[index + 3] = Math.max( 0, Math.min((r * m41 + g * m42 + b * m43 + a * m44 + m45) * 255, 255) ); } self.postMessage({ id: e.data.id, message: imageData }, [ imageData.data.buffer ]); }; }; const getImageSize = (url, cb) => { let image = new Image(); image.onload = () => { const width = image.naturalWidth; const height = image.naturalHeight; image = null; cb(width, height); }; image.src = url; }; const transforms = { 1: () => [1, 0, 0, 1, 0, 0], 2: width => [-1, 0, 0, 1, width, 0], 3: (width, height) => [-1, 0, 0, -1, width, height], 4: (width, height) => [1, 0, 0, -1, 0, height], 5: () => [0, 1, 1, 0, 0, 0], 6: (width, height) => [0, 1, -1, 0, height, 0], 7: (width, height) => [0, -1, -1, 0, height, width], 8: width => [0, -1, 1, 0, 0, width] }; const fixImageOrientation = (ctx, width, height, orientation) => { // no orientation supplied if (orientation === -1) { return; } ctx.transform.apply(ctx, transforms[orientation](width, height)); }; // draws the preview image to canvas const createPreviewImage = (data, width, height, orientation) => { // can't draw on half pixels width = Math.round(width); height = Math.round(height); // draw image const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); // if is rotated incorrectly swap width and height if (orientation >= 5 && orientation <= 8) { [width, height] = [height, width]; } // correct image orientation fixImageOrientation(ctx, width, height, orientation); // draw the image ctx.drawImage(data, 0, 0, width, height); return canvas; }; const isBitmap = file => /^image/.test(file.type) && !/svg/.test(file.type); const MAX_WIDTH = 10; const MAX_HEIGHT = 10; const calculateAverageColor = image => { const scalar = Math.min(MAX_WIDTH / image.width, MAX_HEIGHT / image.height); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const width = (canvas.width = Math.ceil(image.width * scalar)); const height = (canvas.height = Math.ceil(image.height * scalar)); ctx.drawImage(image, 0, 0, width, height); let data = null; try { data = ctx.getImageData(0, 0, width, height).data; } catch (e) { return null; } const l = data.length; let r = 0; let g = 0; let b = 0; let i = 0; for (; i < l; i += 4) { r += data[i] * data[i]; g += data[i + 1] * data[i + 1]; b += data[i + 2] * data[i + 2]; } r = averageColor(r, l); g = averageColor(g, l); b = averageColor(b, l); return { r, g, b }; }; const averageColor = (c, l) => Math.floor(Math.sqrt(c / (l / 4))); const cloneCanvas = (origin, target) => { target = target || document.createElement('canvas'); target.width = origin.width; target.height = origin.height; const ctx = target.getContext('2d'); ctx.drawImage(origin, 0, 0); return target; }; const cloneImageData = imageData => { let id; try { id = new ImageData(imageData.width, imageData.height); } catch (e) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); id = ctx.createImageData(imageData.width, imageData.height); } id.data.set(new Uint8ClampedArray(imageData.data)); return id; }; const loadImage = url => new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'Anonymous'; img.onload = () => { resolve(img); }; img.onerror = e => { reject(e); }; img.src = url; }); const createImageWrapperView = _ => { // create overlay view const OverlayView = createImageOverlayView(_); const ImageView = createImageView(_); const { createWorker } = _.utils; const applyFilter = (root, filter, target) => new Promise(resolve => { // will store image data for future filter updates if (!root.ref.imageData) { root.ref.imageData = target .getContext('2d') .getImageData(0, 0, target.width, target.height); } // get image data reference const imageData = cloneImageData(root.ref.imageData); if (!filter || filter.length !== 20) { target.getContext('2d').putImageData(imageData, 0, 0); return resolve(); } const worker = createWorker(ColorMatrixWorker); worker.post( { imageData, colorMatrix: filter }, response => { // apply filtered colors target.getContext('2d').putImageData(response, 0, 0); // stop worker worker.terminate(); // done! resolve(); }, [imageData.data.buffer] ); }); const removeImageView = (root, imageView) => { root.removeChildView(imageView); imageView.image.width = 1; imageView.image.height = 1; imageView._destroy(); }; // remove an image const shiftImage = ({ root }) => { const imageView = root.ref.images.shift(); imageView.opacity = 0; imageView.translateY = -15; root.ref.imageViewBin.push(imageView); return imageView; }; // add new image const pushImage = ({ root, props, image }) => { const id = props.id; const item = root.query('GET_ITEM', { id }); if (!item) return; const crop = item.getMetadata('crop') || { center: { x: 0.5, y: 0.5 }, flip: { horizontal: false, vertical: false }, zoom: 1, rotation: 0, aspectRatio: null }; const background = root.query( 'GET_IMAGE_TRANSFORM_CANVAS_BACKGROUND_COLOR' ); let markup; let resize; let dirty = false; if (root.query('GET_IMAGE_PREVIEW_MARKUP_SHOW')) { markup = item.getMetadata('markup') || []; resize = item.getMetadata('resize'); dirty = true; } // append image presenter const imageView = root.appendChildView( root.createChildView(ImageView, { id, image, crop, resize, markup, dirty, background, opacity: 0, scaleX: 1.15, scaleY: 1.15, translateY: 15 }), root.childViews.length ); root.ref.images.push(imageView); // reveal the preview image imageView.opacity = 1; imageView.scaleX = 1; imageView.scaleY = 1; imageView.translateY = 0; // the preview is now ready to be drawn setTimeout(() => { root.dispatch('DID_IMAGE_PREVIEW_SHOW', { id }); }, 250); }; const updateImage = ({ root, props }) => { const item = root.query('GET_ITEM', { id: props.id }); if (!item) return; const imageView = root.ref.images[root.ref.images.length - 1]; imageView.crop = item.getMetadata('crop'); imageView.background = root.query( 'GET_IMAGE_TRANSFORM_CANVAS_BACKGROUND_COLOR' ); if (root.query('GET_IMAGE_PREVIEW_MARKUP_SHOW')) { imageView.dirty = true; imageView.resize = item.getMetadata('resize'); imageView.markup = item.getMetadata('markup'); } }; // replace image preview const didUpdateItemMetadata = ({ root, props, action }) => { // only filter and crop trigger redraw if (!/crop|filter|markup|resize/.test(action.change.key)) return; // no images to update, exit if (!root.ref.images.length) return; // no item found, exit const item = root.query('GET_ITEM', { id: props.id }); if (!item) return; // for now, update existing image when filtering if (/filter/.test(action.change.key)) { const imageView = root.ref.images[root.ref.images.length - 1]; applyFilter(root, action.change.value, imageView.image); return; } if (/crop|markup|resize/.test(action.change.key)) { const crop = item.getMetadata('crop'); const image = root.ref.images[root.ref.images.length - 1]; // if aspect ratio has changed, we need to create a new image if ( crop && crop.aspectRatio && image.crop && image.crop.aspectRatio && Math.abs(crop.aspectRatio - image.crop.aspectRatio) > 0.00001 ) { const imageView = shiftImage({ root }); pushImage({ root, props, image: cloneCanvas(imageView.image) }); } // if not, we can update the current image else { updateImage({ root, props }); } } }; const canCreateImageBitmap = file => { // Firefox versions before 58 will freeze when running createImageBitmap // in a Web Worker so we detect those versions and return false for support const userAgent = window.navigator.userAgent; const isFirefox = userAgent.match(/Firefox\/([0-9]+)\./); const firefoxVersion = isFirefox ? parseInt(isFirefox[1]) : null; if (firefoxVersion !== null && firefoxVersion <= 58) return false; return 'createImageBitmap' in window && isBitmap(file); }; /** * Write handler for when preview container has been created */ const didCreatePreviewContainer = ({ root, props }) => { const { id } = props; // we need to get the file data to determine the eventual image size const item = root.query('GET_ITEM', id); if (!item) return; // get url to file (we'll revoke it later on when done) const fileURL = URL.createObjectURL(item.file); // determine image size of this item getImageSize(fileURL, (width, height) => { // we can now scale the panel to the final size root.dispatch('DID_IMAGE_PREVIEW_CALCULATE_SIZE', { id, width, height }); }); }; const drawPreview = ({ root, props }) => { const { id } = props; // we need to get the file data to determine the eventual image size const item = root.query('GET_ITEM', id); if (!item) return; // get url to file (we'll revoke it later on when done) const fileURL = URL.createObjectURL(item.file); // fallback const loadPreviewFallback = () => { // let's scale the image in the main thread :( loadImage(fileURL).then(previewImageLoaded); }; // image is now ready const previewImageLoaded = imageData => { // the file url is no longer needed URL.revokeObjectURL(fileURL); // draw the scaled down version here and use that as source so bitmapdata can be closed // orientation info const exif = item.getMetadata('exif') || {}; const orientation = exif.orientation || -1; // get width and height from action, and swap if orientation is incorrect let { width, height } = imageData; // if no width or height, just return early. if (!width || !height) return; if (orientation >= 5 && orientation <= 8) { [width, height] = [height, width]; } // scale canvas based on pixel density // we multiply by .75 as that creates smaller but still clear images on screens with high res displays const pixelDensityFactor = Math.max(1, window.devicePixelRatio * 0.75); // we want as much pixels to work with as possible, // this multiplies the minimum image resolution, // so when zooming in it doesn't get too blurry const zoomFactor = root.query('GET_IMAGE_PREVIEW_ZOOM_FACTOR'); // imaeg scale factor const scaleFactor = zoomFactor * pixelDensityFactor; // calculate scaled preview image size const previewImageRatio = height / width; // calculate image preview height and width const previewContainerWidth = root.rect.element.width; const previewContainerHeight = root.rect.element.height; let imageWidth = previewContainerWidth; let imageHeight = imageWidth * previewImageRatio; if (previewImageRatio > 1) { imageWidth = Math.min(width, previewContainerWidth * scaleFactor); imageHeight = imageWidth * previewImageRatio; } else { imageHeight = Math.min(height, previewContainerHeight * scaleFactor); imageWidth = imageHeight / previewImageRatio; } // transfer to image tag so no canvas memory wasted on iOS const previewImage = createPreviewImage( imageData, imageWidth, imageHeight, orientation ); // done const done = () => { // calculate average image color, disabled for now const averageColor = root.query( 'GET_IMAGE_PREVIEW_CALCULATE_AVERAGE_IMAGE_COLOR' ) ? calculateAverageColor(data) : null; item.setMetadata('color', averageColor, true); // data has been transferred to canvas ( if was ImageBitmap ) if ('close' in imageData) { imageData.close(); } // show the overlay root.ref.overlayShadow.opacity = 1; // create the first image pushImage({ root, props, image: previewImage }); }; // apply filter const filter = item.getMetadata('filter'); if (filter) { applyFilter(root, filter, previewImage).then(done); } else { done(); } }; // if we support scaling using createImageBitmap we use a worker if (canCreateImageBitmap(item.file)) { // let's scale the image in a worker const worker = createWorker(BitmapWorker); worker.post( { file: item.file }, imageBitmap => { // destroy worker worker.terminate(); // no bitmap returned, must be something wrong, // try the oldschool way if (!imageBitmap) { loadPreviewFallback(); return; } // yay we got our bitmap, let's continue showing the preview previewImageLoaded(imageBitmap); } ); } else { // create fallback preview loadPreviewFallback(); } }; /** * Write handler for when the preview image is ready to be animated */ const didDrawPreview = ({ root }) => { // get last added image const image = root.ref.images[root.ref.images.length - 1]; image.translateY = 0; image.scaleX = 1.0; image.scaleY = 1.0; image.opacity = 1; }; /** * Write handler for when the preview has been loaded */ const restoreOverlay = ({ root }) => { root.ref.overlayShadow.opacity = 1; root.ref.overlayError.opacity = 0; root.ref.overlaySuccess.opacity = 0; }; const didThrowError = ({ root }) => { root.ref.overlayShadow.opacity = 0.25; root.ref.overlayError.opacity = 1; }; const didCompleteProcessing = ({ root }) => { root.ref.overlayShadow.opacity = 0.25; root.ref.overlaySuccess.opacity = 1; }; /** * Constructor */ const create = ({ root }) => { // image view root.ref.images = []; // the preview image data (we need this to filter the image) root.ref.imageData = null; // image bin root.ref.imageViewBin = []; // image overlays root.ref.overlayShadow = root.appendChildView( root.createChildView(OverlayView, { opacity: 0, status: 'idle' }) ); root.ref.overlaySuccess = root.appendChildView( root.createChildView(OverlayView, { opacity: 0, status: 'success' }) ); root.ref.overlayError = root.appendChildView( root.createChildView(OverlayView, { opacity: 0, status: 'failure' }) ); }; return _.utils.createView({ name: 'image-preview-wrapper', create, styles: ['height'], apis: ['height'], destroy: ({ root }) => { // we resize the image so memory on iOS 12 is released more quickly (it seems) root.ref.images.forEach(imageView => { imageView.image.width = 1; imageView.image.height = 1; }); }, didWriteView: ({ root }) => { root.ref.images.forEach(imageView => { imageView.dirty = false; }); }, write: _.utils.createRoute( { // image preview stated DID_IMAGE_PREVIEW_DRAW: didDrawPreview, DID_IMAGE_PREVIEW_CONTAINER_CREATE: didCreatePreviewContainer, DID_FINISH_CALCULATE_PREVIEWSIZE: drawPreview, DID_UPDATE_ITEM_METADATA: didUpdateItemMetadata, // file states DID_THROW_ITEM_LOAD_ERROR: didThrowError, DID_THROW_ITEM_PROCESSING_ERROR: didThrowError, DID_THROW_ITEM_INVALID: didThrowError, DID_COMPLETE_ITEM_PROCESSING: didCompleteProcessing, DID_START_ITEM_PROCESSING: restoreOverlay, DID_REVERT_ITEM_PROCESSING: restoreOverlay }, ({ root }) => { // views on death row const viewsToRemove = root.ref.imageViewBin.filter( imageView => imageView.opacity === 0 ); // views to retain root.ref.imageViewBin = root.ref.imageViewBin.filter( imageView => imageView.opacity > 0 ); // remove these views viewsToRemove.forEach(imageView => removeImageView(root, imageView)); viewsToRemove.length = 0; } ) }); }; /** * Image Preview Plugin */ const plugin = fpAPI => { const { addFilter, utils } = fpAPI; const { Type, createRoute, isFile } = utils; // imagePreviewView const imagePreviewView = createImageWrapperView(fpAPI); // called for each view that is created right after the 'create' method addFilter('CREATE_VIEW', viewAPI => { // get reference to created view const { is, view, query } = viewAPI; // only hook up to item view and only if is enabled for this cropper if (!is('file') || !query('GET_ALLOW_IMAGE_PREVIEW')) return; // create the image preview plugin, but only do so if the item is an image const didLoadItem = ({ root, props }) => { const { id } = props; const item = query('GET_ITEM', id); // item could theoretically have been removed in the mean time if (!item || !isFile(item.file) || item.archived) return; // get the file object const file = item.file; // exit if this is not an image if (!isPreviewableImage(file)) return; // test if is filtered if (!query('GET_IMAGE_PREVIEW_FILTER_ITEM')(item)) return; // exit if image size is too high and no createImageBitmap support // this would simply bring the browser to its knees and that is not what we want const supportsCreateImageBitmap = 'createImageBitmap' in (window || {}); const maxPreviewFileSize = query('GET_IMAGE_PREVIEW_MAX_FILE_SIZE'); if ( !supportsCreateImageBitmap && (maxPreviewFileSize && file.size > maxPreviewFileSize) ) return; // set preview view root.ref.imagePreview = view.appendChildView( view.createChildView(imagePreviewView, { id }) ); // update height if is fixed const fixedPreviewHeight = root.query('GET_IMAGE_PREVIEW_HEIGHT'); if (fixedPreviewHeight) { root.dispatch('DID_UPDATE_PANEL_HEIGHT', { id: item.id, height: fixedPreviewHeight }); } // now ready const queue = !supportsCreateImageBitmap && file.size > query('GET_IMAGE_PREVIEW_MAX_INSTANT_PREVIEW_FILE_SIZE'); root.dispatch('DID_IMAGE_PREVIEW_CONTAINER_CREATE', { id }, queue); }; const rescaleItem = (root, props) => { if (!root.ref.imagePreview) return; let { id } = props; // get item const item = root.query('GET_ITEM', { id }); if (!item) return; // if is fixed height or panel has aspect ratio, exit here, height has already been defined const panelAspectRatio = root.query('GET_PANEL_ASPECT_RATIO'); const itemPanelAspectRatio = root.query('GET_ITEM_PANEL_ASPECT_RATIO'); const fixedHeight = root.query('GET_IMAGE_PREVIEW_HEIGHT'); if (panelAspectRatio || itemPanelAspectRatio || fixedHeight) return; // no data! let { imageWidth, imageHeight } = root.ref; if (!imageWidth || !imageHeight) return; // get height min and max const minPreviewHeight = root.query('GET_IMAGE_PREVIEW_MIN_HEIGHT'); const maxPreviewHeight = root.query('GET_IMAGE_PREVIEW_MAX_HEIGHT'); // orientation info const exif = item.getMetadata('exif') || {}; const orientation = exif.orientation || -1; // get width and height from action, and swap of orientation is incorrect if (orientation >= 5 && orientation <= 8) [imageWidth, imageHeight] = [imageHeight, imageWidth]; // scale up width and height when we're dealing with an SVG if (!isBitmap(item.file) || root.query('GET_IMAGE_PREVIEW_UPSCALE')) { const scalar = 2048 / imageWidth; imageWidth *= scalar; imageHeight *= scalar; } // image aspect ratio const imageAspectRatio = imageHeight / imageWidth; // we need the item to get to the crop size const previewAspectRatio = (item.getMetadata('crop') || {}).aspectRatio || imageAspectRatio; // preview height range let previewHeightMax = Math.max( minPreviewHeight, Math.min(imageHeight, maxPreviewHeight) ); const itemWidth = root.rect.element.width; const previewHeight = Math.min( itemWidth * previewAspectRatio, previewHeightMax ); // request update to panel height root.dispatch('DID_UPDATE_PANEL_HEIGHT', { id: item.id, height: previewHeight }); }; const didResizeView = ({ root }) => { // actions in next write operation root.ref.shouldRescale = true; }; const didUpdateItemMetadata = ({ root, action }) => { if (action.change.key !== 'crop') return; // actions in next write operation root.ref.shouldRescale = true; }; const didCalculatePreviewSize = ({ root, action }) => { // remember dimensions root.ref.imageWidth = action.width; root.ref.imageHeight = action.height; // actions in next write operation root.ref.shouldRescale = true; root.ref.shouldDrawPreview =