filepond-plugin-file-poster
Version:
File Poster Plugin for FilePond
549 lines (456 loc) • 15 kB
JavaScript
/*!
* FilePondPluginFilePoster 2.5.1
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
const IMAGE_SCALE_SPRING_PROPS = {
type: 'spring',
stiffness: 0.5,
damping: 0.45,
mass: 10
};
const createPosterView = _ =>
_.utils.createView({
name: 'file-poster',
tag: 'div',
ignoreRect: true,
create: ({ root }) => {
root.ref.image = document.createElement('img');
root.element.appendChild(root.ref.image);
},
write: _.utils.createRoute({
DID_FILE_POSTER_LOAD: ({ root, props }) => {
const { id } = props;
// get item
const item = root.query('GET_ITEM', { id: props.id });
if (!item) return;
// get poster
const poster = item.getMetadata('poster');
root.ref.image.src = poster;
// let others know of our fabulous achievement (so the image can be faded in)
root.dispatch('DID_FILE_POSTER_DRAW', { id });
}
}),
mixins: {
styles: ['scaleX', 'scaleY', 'opacity'],
animations: {
scaleX: IMAGE_SCALE_SPRING_PROPS,
scaleY: IMAGE_SCALE_SPRING_PROPS,
opacity: { type: 'tween', duration: 750 }
}
}
});
const applyTemplate = (source, target) => {
// copy width and height
target.width = source.width;
target.height = source.height;
// draw the template
const ctx = target.getContext('2d');
ctx.drawImage(source, 0, 0);
};
const createPosterOverlayView = fpAPI =>
fpAPI.utils.createView({
name: 'file-poster-overlay',
tag: 'canvas',
ignoreRect: true,
create: ({ root, props }) => {
applyTemplate(props.template, root.element);
},
mixins: {
styles: ['opacity'],
animations: {
opacity: { type: 'spring', mass: 25 }
}
}
});
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 easeInOutSine = t => -0.5 * (Math.cos(Math.PI * t) - 1);
const addGradientSteps = (
gradient,
color,
alpha = 1,
easeFn = easeInOutSine,
steps = 10,
offset = 0
) => {
const range = 1 - offset;
const rgb = color.join(',');
for (let i = 0; i <= steps; i++) {
const p = i / steps;
const stop = offset + range * p;
gradient.addColorStop(stop, `rgba(${rgb}, ${easeFn(p) * alpha})`);
}
};
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 drawTemplate = (canvas, width, height, color, alphaTarget) => {
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const horizontalCenter = width * 0.5;
const grad = ctx.createRadialGradient(
horizontalCenter,
height + 110,
height - 100,
horizontalCenter,
height + 110,
height + 100
);
addGradientSteps(grad, color, alphaTarget, undefined, 8, 0.4);
ctx.save();
ctx.translate(-width * 0.5, 0);
ctx.scale(2, 1);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
ctx.restore();
};
const hasNavigator = typeof navigator !== 'undefined';
const width = 500;
const height = 200;
const overlayTemplateShadow = hasNavigator && document.createElement('canvas');
const overlayTemplateError = hasNavigator && document.createElement('canvas');
const overlayTemplateSuccess = hasNavigator && document.createElement('canvas');
let itemShadowColor = [40, 40, 40];
let itemErrorColor = [196, 78, 71];
let itemSuccessColor = [54, 151, 99];
if (hasNavigator) {
drawTemplate(overlayTemplateShadow, width, height, itemShadowColor, 0.85);
drawTemplate(overlayTemplateError, width, height, itemErrorColor, 1);
drawTemplate(overlayTemplateSuccess, width, height, itemSuccessColor, 1);
}
const loadImage = (url, crossOriginValue) =>
new Promise((resolve, reject) => {
const img = new Image();
if (typeof crossOrigin === 'string') {
img.crossOrigin = crossOriginValue;
}
img.onload = () => {
resolve(img);
};
img.onerror = e => {
reject(e);
};
img.src = url;
});
const createPosterWrapperView = _ => {
// create overlay view
const overlay = createPosterOverlayView(_);
/**
* 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
const fileURL = item.getMetadata('poster');
// image is now ready
const previewImageLoaded = data => {
// calculate average image color, is in try catch to circumvent any cors errors
const averageColor = root.query(
'GET_FILE_POSTER_CALCULATE_AVERAGE_IMAGE_COLOR'
)
? calculateAverageColor(data)
: null;
item.setMetadata('color', averageColor, true);
// the preview is now ready to be drawn
root.dispatch('DID_FILE_POSTER_LOAD', {
id,
data
});
};
// determine image size of this item
getImageSize(fileURL, (width, height) => {
// we can now scale the panel to the final size
root.dispatch('DID_FILE_POSTER_CALCULATE_SIZE', {
id,
width,
height
});
// create fallback preview
loadImage(
fileURL,
root.query('GET_FILE_POSTER_CROSS_ORIGIN_ATTRIBUTE_VALUE')
).then(previewImageLoaded);
});
};
/**
* Write handler for when the preview has been loaded
*/
const didLoadPreview = ({ root }) => {
root.ref.overlayShadow.opacity = 1;
};
/**
* Write handler for when the preview image is ready to be animated
*/
const didDrawPreview = ({ root }) => {
const { image } = root.ref;
// reveal image
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, props }) => {
// test if colors aren't default item overlay colors
const itemShadowColorProp = root.query(
'GET_FILE_POSTER_ITEM_OVERLAY_SHADOW_COLOR'
);
const itemErrorColorProp = root.query(
'GET_FILE_POSTER_ITEM_OVERLAY_ERROR_COLOR'
);
const itemSuccessColorProp = root.query(
'GET_FILE_POSTER_ITEM_OVERLAY_SUCCESS_COLOR'
);
if (itemShadowColorProp && itemShadowColorProp !== itemShadowColor) {
itemShadowColor = itemShadowColorProp;
drawTemplate(overlayTemplateShadow, width, height, itemShadowColor, 0.85);
}
if (itemErrorColorProp && itemErrorColorProp !== itemErrorColor) {
itemErrorColor = itemErrorColorProp;
drawTemplate(overlayTemplateError, width, height, itemErrorColor, 1);
}
if (itemSuccessColorProp && itemSuccessColorProp !== itemSuccessColor) {
itemSuccessColor = itemSuccessColorProp;
drawTemplate(overlayTemplateSuccess, width, height, itemSuccessColor, 1);
}
// image view
const image = createPosterView(_);
// append image presenter
root.ref.image = root.appendChildView(
root.createChildView(image, {
id: props.id,
scaleX: 1.25,
scaleY: 1.25,
opacity: 0
})
);
// image overlays
root.ref.overlayShadow = root.appendChildView(
root.createChildView(overlay, {
template: overlayTemplateShadow,
opacity: 0
})
);
root.ref.overlaySuccess = root.appendChildView(
root.createChildView(overlay, {
template: overlayTemplateSuccess,
opacity: 0
})
);
root.ref.overlayError = root.appendChildView(
root.createChildView(overlay, {
template: overlayTemplateError,
opacity: 0
})
);
};
return _.utils.createView({
name: 'file-poster-wrapper',
create,
write: _.utils.createRoute({
// image preview stated
DID_FILE_POSTER_LOAD: didLoadPreview,
DID_FILE_POSTER_DRAW: didDrawPreview,
DID_FILE_POSTER_CONTAINER_CREATE: didCreatePreviewContainer,
// 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
})
});
};
/**
* File Poster Plugin
*/
const plugin = fpAPI => {
const { addFilter, utils } = fpAPI;
const { Type, createRoute } = utils;
// filePosterView
const filePosterView = createPosterWrapperView(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_FILE_POSTER')) return;
// create the file poster plugin, but only do so if the item is an image
const didLoadItem = ({ root, props }) => {
updateItemPoster(root, props);
};
const didUpdateItemMetadata = ({ root, props, action }) => {
if (!/poster/.test(action.change.key)) return;
updateItemPoster(root, props);
};
const updateItemPoster = (root, props) => {
const { id } = props;
const item = query('GET_ITEM', id);
// item could theoretically have been removed in the mean time
if (!item || !item.getMetadata('poster') || item.archived) return;
// don't update if is the same poster
if (root.ref.previousPoster === item.getMetadata('poster')) return;
root.ref.previousPoster = item.getMetadata('poster');
// test if is filtered
if (!query('GET_FILE_POSTER_FILTER_ITEM')(item)) return;
if (root.ref.filePoster) {
view.removeChildView(root.ref.filePoster);
}
// set preview view
root.ref.filePoster = view.appendChildView(
view.createChildView(filePosterView, { id })
);
// now ready
root.dispatch('DID_FILE_POSTER_CONTAINER_CREATE', { id });
};
const didCalculatePreviewSize = ({ root, action }) => {
// no poster set
if (!root.ref.filePoster) return;
// remember dimensions
root.ref.imageWidth = action.width;
root.ref.imageHeight = action.height;
root.ref.shouldUpdatePanelHeight = true;
root.dispatch('KICK');
};
const getPosterHeight = ({ root }) => {
let fixedPosterHeight = root.query('GET_FILE_POSTER_HEIGHT');
// if fixed height: return fixed immediately
if (fixedPosterHeight) {
return fixedPosterHeight;
}
const minPosterHeight = root.query('GET_FILE_POSTER_MIN_HEIGHT');
const maxPosterHeight = root.query('GET_FILE_POSTER_MAX_HEIGHT');
// if natural height is smaller than minHeight: return min height
if (minPosterHeight && root.ref.imageHeight < minPosterHeight) {
return minPosterHeight;
}
let height =
root.rect.element.width * (root.ref.imageHeight / root.ref.imageWidth);
if (minPosterHeight && height < minPosterHeight) {
return minPosterHeight;
}
if (maxPosterHeight && height > maxPosterHeight) {
return maxPosterHeight;
}
return height;
};
// start writing
view.registerWriter(
createRoute(
{
DID_LOAD_ITEM: didLoadItem,
DID_FILE_POSTER_CALCULATE_SIZE: didCalculatePreviewSize,
DID_UPDATE_ITEM_METADATA: didUpdateItemMetadata
},
({ root, props }) => {
// don't run without poster
if (!root.ref.filePoster) return;
// don't do anything while hidden
if (root.rect.element.hidden) return;
// should we redraw
if (root.ref.shouldUpdatePanelHeight) {
// time to resize the parent panel
root.dispatch('DID_UPDATE_PANEL_HEIGHT', {
id: props.id,
height: getPosterHeight({ root })
});
// done!
root.ref.shouldUpdatePanelHeight = false;
}
}
)
);
});
// expose plugin
return {
options: {
// Enable or disable file poster
allowFilePoster: [true, Type.BOOLEAN],
// Fixed preview height
filePosterHeight: [null, Type.INT],
// Min image height
filePosterMinHeight: [null, Type.INT],
// Max image height
filePosterMaxHeight: [null, Type.INT],
// filters file items to determine which are shown as poster
filePosterFilterItem: [() => true, Type.FUNCTION],
// Enables or disables reading average image color
filePosterCalculateAverageImageColor: [false, Type.BOOLEAN],
// Allows setting the value of the CORS attribute (null is don't set attribute)
filePosterCrossOriginAttributeValue: ['Anonymous', Type.STRING],
// Colors used for item overlay gradient
filePosterItemOverlayShadowColor: [null, Type.ARRAY],
filePosterItemOverlayErrorColor: [null, Type.ARRAY],
filePosterItemOverlaySuccessColor: [null, Type.ARRAY]
}
};
};
// fire pluginloaded event if running in browser, this allows registering the plugin when using async script tags
const isBrowser =
typeof window !== 'undefined' && typeof window.document !== 'undefined';
if (isBrowser) {
document.dispatchEvent(
new CustomEvent('FilePond:pluginloaded', { detail: plugin })
);
}
export default plugin;