filepond
Version:
FilePond, Where files go to stretch their bits.
8,776 lines (7,340 loc) • 224 kB
JavaScript
/*!
* FilePond 4.6.0
* Licensed under MIT, https://opensource.org/licenses/MIT/
* Please visit https://pqina.nl/filepond/ for details.
*/
/* eslint-disable */
const isNode = value => value instanceof HTMLElement;
const createStore = (initialState, queries = [], actions = []) => {
// internal state
const state = {
...initialState
};
// contains all actions for next frame, is clear when actions are requested
const actionQueue = [];
const dispatchQueue = [];
// returns a duplicate of the current state
const getState = () => ({ ...state });
// returns a duplicate of the actions array and clears the actions array
const processActionQueue = () => {
// create copy of actions queue
const queue = [...actionQueue];
// clear actions queue (we don't want no double actions)
actionQueue.length = 0;
return queue;
};
// processes actions that might block the main UI thread
const processDispatchQueue = () => {
// create copy of actions queue
const queue = [...dispatchQueue];
// clear actions queue (we don't want no double actions)
dispatchQueue.length = 0;
// now dispatch these actions
queue.forEach(({ type, data }) => {
dispatch(type, data);
});
};
// adds a new action, calls its handler and
const dispatch = (type, data, isBlocking) => {
// is blocking action
if (isBlocking) {
dispatchQueue.push({
type,
data
});
return;
}
// if this action has a handler, handle the action
if (actionHandlers[type]) {
actionHandlers[type](data);
}
// now add action
actionQueue.push({
type,
data
});
};
const query = (str, ...args) =>
queryHandles[str] ? queryHandles[str](...args) : null;
const api = {
getState,
processActionQueue,
processDispatchQueue,
dispatch,
query
};
let queryHandles = {};
queries.forEach(query => {
queryHandles = {
...query(state),
...queryHandles
};
});
let actionHandlers = {};
actions.forEach(action => {
actionHandlers = {
...action(dispatch, query, state),
...actionHandlers
};
});
return api;
};
const defineProperty = (obj, property, definition) => {
if (typeof definition === 'function') {
obj[property] = definition;
return;
}
Object.defineProperty(obj, property, { ...definition });
};
const forin = (obj, cb) => {
for (const key in obj) {
if (!obj.hasOwnProperty(key)) {
continue;
}
cb(key, obj[key]);
}
};
const createObject = definition => {
const obj = {};
forin(definition, property => {
defineProperty(obj, property, definition[property]);
});
return obj;
};
const attr = (node, name, value = null) => {
if (value === null) {
return node.getAttribute(name) || node.hasAttribute(name);
}
node.setAttribute(name, value);
};
const ns = 'http://www.w3.org/2000/svg';
const svgElements = ['svg', 'path']; // only svg elements used
const isSVGElement = tag => svgElements.includes(tag);
const createElement = (tag, className, attributes = {}) => {
if (typeof className === 'object') {
attributes = className;
className = null;
}
const element = isSVGElement(tag)
? document.createElementNS(ns, tag)
: document.createElement(tag);
if (className) {
if (isSVGElement(tag)) {
attr(element, 'class', className);
} else {
element.className = className;
}
}
forin(attributes, (name, value) => {
attr(element, name, value);
});
return element;
};
const appendChild = parent => (child, index) => {
if (typeof index !== 'undefined' && parent.children[index]) {
parent.insertBefore(child, parent.children[index]);
} else {
parent.appendChild(child);
}
};
const appendChildView = (parent, childViews) => (view, index) => {
if (typeof index !== 'undefined') {
childViews.splice(index, 0, view);
} else {
childViews.push(view);
}
return view;
};
const removeChildView = (parent, childViews) => view => {
// remove from child views
childViews.splice(childViews.indexOf(view), 1);
// remove the element
if (view.element.parentNode) {
parent.removeChild(view.element);
}
return view;
};
const getViewRect = (elementRect, childViews, offset, scale) => {
const left = offset[0] || elementRect.left;
const top = offset[1] || elementRect.top;
const right = left + elementRect.width;
const bottom = top + elementRect.height * (scale[1] || 1);
const rect = {
// the rectangle of the element itself
element: {
...elementRect
},
// the rectangle of the element expanded to contain its children, does not include any margins
inner: {
left: elementRect.left,
top: elementRect.top,
right: elementRect.right,
bottom: elementRect.bottom
},
// the rectangle of the element expanded to contain its children including own margin and child margins
// margins will be added after we've recalculated the size
outer: {
left,
top,
right,
bottom
}
};
// expand rect to fit all child rectangles
childViews
.filter(childView => !childView.isRectIgnored())
.map(childView => childView.rect)
.forEach(childViewRect => {
expandRect(rect.inner, { ...childViewRect.inner });
expandRect(rect.outer, { ...childViewRect.outer });
});
// calculate inner width and height
calculateRectSize(rect.inner);
// append additional margin (top and left margins are included in top and left automatically)
rect.outer.bottom += rect.element.marginBottom;
rect.outer.right += rect.element.marginRight;
// calculate outer width and height
calculateRectSize(rect.outer);
return rect;
};
const expandRect = (parent, child) => {
// adjust for parent offset
child.top += parent.top;
child.right += parent.left;
child.bottom += parent.top;
child.left += parent.left;
if (child.bottom > parent.bottom) {
parent.bottom = child.bottom;
}
if (child.right > parent.right) {
parent.right = child.right;
}
};
const calculateRectSize = rect => {
rect.width = rect.right - rect.left;
rect.height = rect.bottom - rect.top;
};
const isNumber = value => typeof value === 'number';
/**
* Determines if position is at destination
* @param position
* @param destination
* @param velocity
* @param errorMargin
* @returns {boolean}
*/
const thereYet = (position, destination, velocity, errorMargin = 0.001) => {
return (
Math.abs(position - destination) < errorMargin &&
Math.abs(velocity) < errorMargin
);
};
/**
* Spring animation
*/
const spring =
// default options
({ stiffness = 0.5, damping = 0.75, mass = 10 } = {}) =>
// method definition
{
let target = null;
let position = null;
let velocity = 0;
let resting = false;
// updates spring state
const interpolate = () => {
// in rest, don't animate
if (resting) {
return;
}
// need at least a target or position to do springy things
if (!(isNumber(target) && isNumber(position))) {
resting = true;
velocity = 0;
return;
}
// calculate spring force
const f = -(position - target) * stiffness;
// update velocity by adding force based on mass
velocity += f / mass;
// update position by adding velocity
position += velocity;
// slow down based on amount of damping
velocity *= damping;
// we've arrived if we're near target and our velocity is near zero
if (thereYet(position, target, velocity)) {
position = target;
velocity = 0;
resting = true;
// we done
api.onupdate(position);
api.oncomplete(position);
} else {
// progress update
api.onupdate(position);
}
};
/**
* Set new target value
* @param value
*/
const setTarget = value => {
// if currently has no position, set target and position to this value
if (isNumber(value) && !isNumber(position)) {
position = value;
}
// next target value will not be animated to
if (target === null) {
target = value;
position = value;
}
// let start moving to target
target = value;
// already at target
if (position === target || typeof target === 'undefined') {
// now resting as target is current position, stop moving
resting = true;
velocity = 0;
// done!
api.onupdate(position);
api.oncomplete(position);
return;
}
resting = false;
};
// need 'api' to call onupdate callback
const api = createObject({
interpolate,
target: {
set: setTarget,
get: () => target
},
resting: {
get: () => resting
},
onupdate: value => {},
oncomplete: value => {}
});
return api;
};
const easeLinear = t => t;
const easeInOutQuad = t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t);
const tween =
// default values
({ duration = 500, easing = easeInOutQuad, delay = 0 } = {}) =>
// method definition
{
let start = null;
let t;
let p;
let resting = true;
let reverse = false;
let target = null;
const interpolate = ts => {
if (resting || target === null) {
return;
}
if (start === null) {
start = ts;
}
if (ts - start < delay) {
return;
}
t = ts - start - delay;
if (t < duration) {
p = t / duration;
api.onupdate((t >= 0 ? easing(reverse ? 1 - p : p) : 0) * target);
} else {
t = 1;
p = reverse ? 0 : 1;
api.onupdate(p * target);
api.oncomplete(p * target);
resting = true;
}
};
// need 'api' to call onupdate callback
const api = createObject({
interpolate,
target: {
get: () => (reverse ? 0 : target),
set: value => {
// is initial value
if (target === null) {
target = value;
api.onupdate(value);
api.oncomplete(value);
return;
}
// want to tween to a smaller value and have a current value
if (value < target) {
target = 1;
reverse = true;
} else {
// not tweening to a smaller value
reverse = false;
target = value;
}
// let's go!
resting = false;
start = null;
}
},
resting: {
get: () => resting
},
onupdate: value => {},
oncomplete: value => {}
});
return api;
};
const animator = {
spring,
tween
};
/*
{ type: 'spring', stiffness: .5, damping: .75, mass: 10 };
{ translation: { type: 'spring', ... }, ... }
{ translation: { x: { type: 'spring', ... } } }
*/
const createAnimator = (definition, category, property) => {
// default is single definition
// we check if transform is set, if so, we check if property is set
const def =
definition[category] && typeof definition[category][property] === 'object'
? definition[category][property]
: definition[category] || definition;
const type = typeof def === 'string' ? def : def.type;
const props = typeof def === 'object' ? { ...def } : {};
return animator[type] ? animator[type](props) : null;
};
const addGetSet = (keys, obj, props, overwrite = false) => {
obj = Array.isArray(obj) ? obj : [obj];
obj.forEach(o => {
keys.forEach(key => {
let name = key;
let getter = () => props[key];
let setter = value => (props[key] = value);
if (typeof key === 'object') {
name = key.key;
getter = key.getter || getter;
setter = key.setter || setter;
}
if (o[name] && !overwrite) {
return;
}
o[name] = {
get: getter,
set: setter
};
});
});
};
const isDefined = value => value != null;
// add to state,
// add getters and setters to internal and external api (if not set)
// setup animators
const animations = ({
mixinConfig,
viewProps,
viewInternalAPI,
viewExternalAPI,
viewState
}) => {
// initial properties
const initialProps = { ...viewProps };
// list of all active animations
const animations = [];
// setup animators
forin(mixinConfig, (property, animation) => {
const animator = createAnimator(animation);
if (!animator) {
return;
}
// when the animator updates, update the view state value
animator.onupdate = value => {
viewProps[property] = value;
};
// set animator target
animator.target = initialProps[property];
// when value is set, set the animator target value
const prop = {
key: property,
setter: value => {
// if already at target, we done!
if (animator.target === value) {
return;
}
animator.target = value;
},
getter: () => viewProps[property]
};
// add getters and setters
addGetSet([prop], [viewInternalAPI, viewExternalAPI], viewProps, true);
// add it to the list for easy updating from the _write method
animations.push(animator);
});
// expose internal write api
return {
write: ts => {
let resting = true;
animations.forEach(animation => {
if (!animation.resting) {
resting = false;
}
animation.interpolate(ts);
});
return resting;
},
destroy: () => {}
};
};
const addEvent = element => (type, fn) => {
element.addEventListener(type, fn);
};
const removeEvent = element => (type, fn) => {
element.removeEventListener(type, fn);
};
// mixin
const listeners = ({
mixinConfig,
viewProps,
viewInternalAPI,
viewExternalAPI,
viewState,
view
}) => {
const events = [];
const add = addEvent(view.element);
const remove = removeEvent(view.element);
viewExternalAPI.on = (type, fn) => {
events.push({
type,
fn
});
add(type, fn);
};
viewExternalAPI.off = (type, fn) => {
events.splice(
events.findIndex(event => event.type === type && event.fn === fn),
1
);
remove(type, fn);
};
return {
write: () => {
// not busy
return true;
},
destroy: () => {
events.forEach(event => {
remove(event.type, event.fn);
});
}
};
};
// add to external api and link to props
const apis = ({ mixinConfig, viewProps, viewExternalAPI }) => {
addGetSet(mixinConfig, viewExternalAPI, viewProps);
};
// add to state,
// add getters and setters to internal and external api (if not set)
// set initial state based on props in viewProps
// apply as transforms each frame
const defaults = {
opacity: 1,
scaleX: 1,
scaleY: 1,
translateX: 0,
translateY: 0,
rotateX: 0,
rotateY: 0,
rotateZ: 0,
originX: 0,
originY: 0
};
const styles = ({
mixinConfig,
viewProps,
viewInternalAPI,
viewExternalAPI,
view
}) => {
// initial props
const initialProps = { ...viewProps };
// current props
const currentProps = {};
// we will add those properties to the external API and link them to the viewState
addGetSet(mixinConfig, [viewInternalAPI, viewExternalAPI], viewProps);
// override rect on internal and external rect getter so it takes in account transforms
const getOffset = () => [
viewProps['translateX'] || 0,
viewProps['translateY'] || 0
];
const getScale = () => [viewProps['scaleX'] || 0, viewProps['scaleY'] || 0];
const getRect = () =>
view.rect
? getViewRect(view.rect, view.childViews, getOffset(), getScale())
: null;
viewInternalAPI.rect = { get: getRect };
viewExternalAPI.rect = { get: getRect };
// apply view props
mixinConfig.forEach(key => {
viewProps[key] =
typeof initialProps[key] === 'undefined'
? defaults[key]
: initialProps[key];
});
// expose api
return {
write: () => {
// see if props have changed
if (!propsHaveChanged(currentProps, viewProps)) {
return;
}
// moves element to correct position on screen
applyStyles(view.element, viewProps);
// store new transforms
Object.assign(currentProps, { ...viewProps });
// no longer busy
return true;
},
destroy: () => {}
};
};
const propsHaveChanged = (currentProps, newProps) => {
// different amount of keys
if (Object.keys(currentProps).length !== Object.keys(newProps).length) {
return true;
}
// lets analyze the individual props
for (const prop in newProps) {
if (newProps[prop] !== currentProps[prop]) {
return true;
}
}
return false;
};
const applyStyles = (
element,
{
opacity,
perspective,
translateX,
translateY,
scaleX,
scaleY,
rotateX,
rotateY,
rotateZ,
originX,
originY,
width,
height
}
) => {
let transforms = '';
let styles = '';
// handle transform origin
if (isDefined(originX) || isDefined(originY)) {
styles += `transform-origin: ${originX || 0}px ${originY || 0}px;`;
}
// transform order is relevant
// 0. perspective
if (isDefined(perspective)) {
transforms += `perspective(${perspective}px) `;
}
// 1. translate
if (isDefined(translateX) || isDefined(translateY)) {
transforms += `translate3d(${translateX || 0}px, ${translateY || 0}px, 0) `;
}
// 2. scale
if (isDefined(scaleX) || isDefined(scaleY)) {
transforms += `scale3d(${isDefined(scaleX) ? scaleX : 1}, ${
isDefined(scaleY) ? scaleY : 1
}, 1) `;
}
// 3. rotate
if (isDefined(rotateZ)) {
transforms += `rotateZ(${rotateZ}rad) `;
}
if (isDefined(rotateX)) {
transforms += `rotateX(${rotateX}rad) `;
}
if (isDefined(rotateY)) {
transforms += `rotateY(${rotateY}rad) `;
}
// add transforms
if (transforms.length) {
styles += `transform:${transforms};`;
}
// add opacity
if (isDefined(opacity)) {
styles += `opacity:${opacity};`;
// if we reach zero, we make the element inaccessible
if (opacity === 0) {
styles += `visibility:hidden;`;
}
// if we're below 100% opacity this element can't be clicked
if (opacity < 1) {
styles += `pointer-events:none;`;
}
}
// add height
if (isDefined(height)) {
styles += `height:${height}px;`;
}
// add width
if (isDefined(width)) {
styles += `width:${width}px;`;
}
// apply styles
const elementCurrentStyle = element.elementCurrentStyle || '';
// if new styles does not match current styles, lets update!
if (
styles.length !== elementCurrentStyle.length ||
styles !== elementCurrentStyle
) {
element.setAttribute('style', styles);
// store current styles so we can compare them to new styles later on
// _not_ getting the style attribute is faster
element.elementCurrentStyle = styles;
}
};
const Mixins = {
styles,
listeners,
animations,
apis
};
const updateRect = (rect = {}, element = {}, style = {}) => {
if (!element.layoutCalculated) {
rect.paddingTop = parseInt(style.paddingTop, 10) || 0;
rect.marginTop = parseInt(style.marginTop, 10) || 0;
rect.marginRight = parseInt(style.marginRight, 10) || 0;
rect.marginBottom = parseInt(style.marginBottom, 10) || 0;
rect.marginLeft = parseInt(style.marginLeft, 10) || 0;
element.layoutCalculated = true;
}
rect.left = element.offsetLeft || 0;
rect.top = element.offsetTop || 0;
rect.width = element.offsetWidth || 0;
rect.height = element.offsetHeight || 0;
rect.right = rect.left + rect.width;
rect.bottom = rect.top + rect.height;
rect.scrollTop = element.scrollTop;
rect.hidden = element.offsetParent === null;
return rect;
};
const createView =
// default view definition
({
// element definition
tag = 'div',
name = null,
attributes = {},
// view interaction
read = () => {},
write = () => {},
create = () => {},
destroy = () => {},
// hooks
filterFrameActionsForChild = (child, actions) => actions,
didCreateView = () => {},
didWriteView = () => {},
// rect related
ignoreRect = false,
ignoreRectUpdate = false,
// mixins
mixins = []
} = {}) => (
// each view requires reference to store
store,
// specific properties for this view
props = {}
) => {
// root element should not be changed
const element = createElement(tag, `filepond--${name}`, attributes);
// style reference should also not be changed
const style = window.getComputedStyle(element, null);
// element rectangle
const rect = updateRect();
let frameRect = null;
// rest state
let isResting = false;
// pretty self explanatory
const childViews = [];
// loaded mixins
const activeMixins = [];
// references to created children
const ref = {};
// state used for each instance
const state = {};
// list of writers that will be called to update this view
const writers = [
write // default writer
];
const readers = [
read // default reader
];
const destroyers = [
destroy // default destroy
];
// core view methods
const getElement = () => element;
const getChildViews = () => childViews.concat();
const getReference = () => ref;
const createChildView = store => (view, props) => view(store, props);
const getRect = () => {
if (frameRect) {
return frameRect;
}
frameRect = getViewRect(rect, childViews, [0, 0], [1, 1]);
return frameRect;
};
const getStyle = () => style;
/**
* Read data from DOM
* @private
*/
const _read = () => {
frameRect = null;
// read child views
childViews.forEach(child => child._read());
const shouldUpdate = !(ignoreRectUpdate && rect.width && rect.height);
if (shouldUpdate) {
updateRect(rect, element, style);
}
// readers
const api = { root: internalAPI, props, rect };
readers.forEach(reader => reader(api));
};
/**
* Write data to DOM
* @private
*/
const _write = (ts, frameActions, shouldOptimize) => {
// if no actions, we assume that the view is resting
let resting = frameActions.length === 0;
// writers
writers.forEach(writer => {
const writerResting = writer({
props,
root: internalAPI,
actions: frameActions,
timestamp: ts,
shouldOptimize
});
if (writerResting === false) {
resting = false;
}
});
// run mixins
activeMixins.forEach(mixin => {
// if one of the mixins is still busy after write operation, we are not resting
const mixinResting = mixin.write(ts);
if (mixinResting === false) {
resting = false;
}
});
// updates child views that are currently attached to the DOM
childViews
.filter(child => !!child.element.parentNode)
.forEach(child => {
// if a child view is not resting, we are not resting
const childResting = child._write(
ts,
filterFrameActionsForChild(child, frameActions),
shouldOptimize
);
if (!childResting) {
resting = false;
}
});
// append new elements to DOM and update those
childViews
//.filter(child => !child.element.parentNode)
.forEach((child, index) => {
// skip
if (child.element.parentNode) {
return;
}
// append to DOM
internalAPI.appendChild(child.element, index);
// call read (need to know the size of these elements)
child._read();
// re-call write
child._write(
ts,
filterFrameActionsForChild(child, frameActions),
shouldOptimize
);
// we just added somthing to the dom, no rest
resting = false;
});
// update resting state
isResting = resting;
didWriteView({
props,
root: internalAPI,
actions: frameActions,
timestamp: ts
});
// let parent know if we are resting
return resting;
};
const _destroy = () => {
activeMixins.forEach(mixin => mixin.destroy());
destroyers.forEach(destroyer => {
destroyer({ root: internalAPI, props });
});
childViews.forEach(child => child._destroy());
};
// sharedAPI
const sharedAPIDefinition = {
element: {
get: getElement
},
style: {
get: getStyle
},
childViews: {
get: getChildViews
}
};
// private API definition
const internalAPIDefinition = {
...sharedAPIDefinition,
rect: {
get: getRect
},
// access to custom children references
ref: {
get: getReference
},
// dom modifiers
is: needle => name === needle,
appendChild: appendChild(element),
createChildView: createChildView(store),
linkView: view => {
childViews.push(view);
return view;
},
unlinkView: view => {
childViews.splice(childViews.indexOf(view), 1);
},
appendChildView: appendChildView(element, childViews),
removeChildView: removeChildView(element, childViews),
registerWriter: writer => writers.push(writer),
registerReader: reader => readers.push(reader),
registerDestroyer: destroyer => destroyers.push(destroyer),
invalidateLayout: () => (element.layoutCalculated = false),
// access to data store
dispatch: store.dispatch,
query: store.query
};
// public view API methods
const externalAPIDefinition = {
element: {
get: getElement
},
childViews: {
get: getChildViews
},
rect: {
get: getRect
},
resting: {
get: () => isResting
},
isRectIgnored: () => ignoreRect,
_read,
_write,
_destroy
};
// mixin API methods
const mixinAPIDefinition = {
...sharedAPIDefinition,
rect: {
get: () => rect
}
};
// add mixin functionality
Object.keys(mixins)
.sort((a, b) => {
// move styles to the back of the mixin list (so adjustments of other mixins are applied to the props correctly)
if (a === 'styles') {
return 1;
} else if (b === 'styles') {
return -1;
}
return 0;
})
.forEach(key => {
const mixinAPI = Mixins[key]({
mixinConfig: mixins[key],
viewProps: props,
viewState: state,
viewInternalAPI: internalAPIDefinition,
viewExternalAPI: externalAPIDefinition,
view: createObject(mixinAPIDefinition)
});
if (mixinAPI) {
activeMixins.push(mixinAPI);
}
});
// construct private api
const internalAPI = createObject(internalAPIDefinition);
// create the view
create({
root: internalAPI,
props
});
// append created child views to root node
const childCount = element.children.length; // need to know the current child count so appending happens in correct order
childViews.forEach((child, index) => {
internalAPI.appendChild(child.element, childCount + index);
});
// call did create
didCreateView(internalAPI);
// expose public api
return createObject(externalAPIDefinition, props);
};
const createPainter = (read, write, fps = 60) => {
const name = '__framePainter';
// set global painter
if (window[name]) {
window[name].readers.push(read);
window[name].writers.push(write);
return;
}
window[name] = {
readers: [read],
writers: [write]
};
const painter = window[name];
const interval = 1000 / fps;
let last = null;
let id = null;
let requestTick = null;
let cancelTick = null;
const setTimerType = () => {
if (document.hidden) {
requestTick = () =>
window.setTimeout(() => tick(performance.now()), interval);
cancelTick = () => window.clearTimeout(id);
} else {
requestTick = () => window.requestAnimationFrame(tick);
cancelTick = () => window.cancelAnimationFrame(id);
}
};
document.addEventListener('visibilitychange', () => {
if (cancelTick) cancelTick();
setTimerType();
tick(performance.now());
});
const tick = ts => {
// queue next tick
id = requestTick(tick);
// limit fps
if (!last) {
last = ts;
}
const delta = ts - last;
if (delta <= interval) {
// skip frame
return;
}
// align next frame
last = ts - (delta % interval);
// update view
painter.readers.forEach(read => read());
painter.writers.forEach(write => write(ts));
};
setTimerType();
tick(performance.now());
return {
pause: () => {
cancelTick(id);
}
};
};
const createRoute = (routes, fn) => ({
root,
props,
actions = [],
timestamp,
shouldOptimize
}) => {
actions
.filter(action => routes[action.type])
.forEach(action =>
routes[action.type]({
root,
props,
action: action.data,
timestamp,
shouldOptimize
})
);
if (fn) {
fn({ root, props, actions, timestamp, shouldOptimize });
}
};
const insertBefore = (newNode, referenceNode) =>
referenceNode.parentNode.insertBefore(newNode, referenceNode);
const insertAfter = (newNode, referenceNode) => {
return referenceNode.parentNode.insertBefore(
newNode,
referenceNode.nextSibling
);
};
const isArray = value => Array.isArray(value);
const isEmpty = value => value == null;
const trim = str => str.trim();
const toString = value => '' + value;
const toArray = (value, splitter = ',') => {
if (isEmpty(value)) {
return [];
}
if (isArray(value)) {
return value;
}
return toString(value)
.split(splitter)
.map(trim)
.filter(str => str.length);
};
const isBoolean = value => typeof value === 'boolean';
const toBoolean = value => (isBoolean(value) ? value : value === 'true');
const isString = value => typeof value === 'string';
const toNumber = value =>
isNumber(value)
? value
: isString(value)
? toString(value).replace(/[a-z]+/gi, '')
: 0;
const toInt = value => parseInt(toNumber(value), 10);
const toFloat = value => parseFloat(toNumber(value));
const isInt = value =>
isNumber(value) && isFinite(value) && Math.floor(value) === value;
const toBytes = value => {
// is in bytes
if (isInt(value)) {
return value;
}
// is natural file size
let naturalFileSize = toString(value).trim();
// if is value in megabytes
if (/MB$/i.test(naturalFileSize)) {
naturalFileSize = naturalFileSize.replace(/MB$i/, '').trim();
return toInt(naturalFileSize) * 1000 * 1000;
}
// if is value in kilobytes
if (/KB/i.test(naturalFileSize)) {
naturalFileSize = naturalFileSize.replace(/KB$i/, '').trim();
return toInt(naturalFileSize) * 1000;
}
return toInt(naturalFileSize);
};
const isFunction = value => typeof value === 'function';
const toFunctionReference = string => {
let ref = self;
let levels = string.split('.');
let level = null;
while ((level = levels.shift())) {
ref = ref[level];
if (!ref) {
return null;
}
}
return ref;
};
const methods = {
process: 'POST',
revert: 'DELETE',
fetch: 'GET',
restore: 'GET',
load: 'GET'
};
const createServerAPI = outline => {
const api = {};
api.url = isString(outline) ? outline : outline.url || '';
api.timeout = outline.timeout ? parseInt(outline.timeout, 10) : 0;
api.headers = outline.headers ? outline.headers : {};
forin(methods, key => {
api[key] = createAction(
key,
outline[key],
methods[key],
api.timeout,
api.headers
);
});
// special treatment for remove
api.remove = outline.remove || null;
// remove generic headers from api object
delete api.headers;
return api;
};
const createAction = (name, outline, method, timeout, headers) => {
// is explicitely set to null so disable
if (outline === null) {
return null;
}
// if is custom function, done! Dev handles everything.
if (typeof outline === 'function') {
return outline;
}
// build action object
const action = {
url: method === 'GET' ? `?${name}=` : '',
method,
headers,
withCredentials: false,
timeout,
onload: null,
ondata: null,
onerror: null
};
// is a single url
if (isString(outline)) {
action.url = outline;
return action;
}
// overwrite
Object.assign(action, outline);
// see if should reformat headers;
if (isString(action.headers)) {
const parts = action.headers.split(/:(.+)/);
action.headers = {
header: parts[0],
value: parts[1]
};
}
// if is bool withCredentials
action.withCredentials = toBoolean(action.withCredentials);
return action;
};
const toServerAPI = value => createServerAPI(value);
const isNull = value => value === null;
const isObject = value => typeof value === 'object' && value !== null;
const isAPI = value => {
return (
isObject(value) &&
isString(value.url) &&
isObject(value.process) &&
isObject(value.revert) &&
isObject(value.restore) &&
isObject(value.fetch)
);
};
const getType = value => {
if (isArray(value)) {
return 'array';
}
if (isNull(value)) {
return 'null';
}
if (isInt(value)) {
return 'int';
}
if (/^[0-9]+ ?(?:GB|MB|KB)$/gi.test(value)) {
return 'bytes';
}
if (isAPI(value)) {
return 'api';
}
return typeof value;
};
const replaceSingleQuotes = str =>
str
.replace(/{\s*'/g, '{"')
.replace(/'\s*}/g, '"}')
.replace(/'\s*:/g, '":')
.replace(/:\s*'/g, ':"')
.replace(/,\s*'/g, ',"')
.replace(/'\s*,/g, '",');
const conversionTable = {
array: toArray,
boolean: toBoolean,
int: value => (getType(value) === 'bytes' ? toBytes(value) : toInt(value)),
number: toFloat,
float: toFloat,
bytes: toBytes,
string: value => (isFunction(value) ? value : toString(value)),
function: value => toFunctionReference(value),
serverapi: toServerAPI,
object: value => {
try {
return JSON.parse(replaceSingleQuotes(value));
} catch (e) {
return null;
}
}
};
const convertTo = (value, type) => conversionTable[type](value);
const getValueByType = (newValue, defaultValue, valueType) => {
// can always assign default value
if (newValue === defaultValue) {
return newValue;
}
// get the type of the new value
let newValueType = getType(newValue);
// is valid type?
if (newValueType !== valueType) {
// is string input, let's attempt to convert
const convertedValue = convertTo(newValue, valueType);
// what is the type now
newValueType = getType(convertedValue);
// no valid conversions found
if (convertedValue === null) {
throw `Trying to assign value with incorrect type to "${option}", allowed type: "${valueType}"`;
} else {
newValue = convertedValue;
}
}
// assign new value
return newValue;
};
const createOption = (defaultValue, valueType) => {
let currentValue = defaultValue;
return {
enumerable: true,
get: () => currentValue,
set: newValue => {
currentValue = getValueByType(newValue, defaultValue, valueType);
}
};
};
const createOptions = options => {
const obj = {};
forin(options, prop => {
const optionDefinition = options[prop];
obj[prop] = createOption(optionDefinition[0], optionDefinition[1]);
});
return createObject(obj);
};
const createInitialState = options => ({
// model
items: [],
// timeout used for calling update items
listUpdateTimeout: null,
// timeout used for stacking metadata updates
itemUpdateTimeout: null,
// queue of items waiting to be processed
processingQueue: [],
// options
options: createOptions(options)
});
const fromCamels = (string, separator = '-') =>
string
.split(/(?=[A-Z])/)
.map(part => part.toLowerCase())
.join(separator);
const createOptionAPI = (store, options) => {
const obj = {};
forin(options, key => {
obj[key] = {
get: () => store.getState().options[key],
set: value => {
store.dispatch(`SET_${fromCamels(key, '_').toUpperCase()}`, {
value
});
}
};
});
return obj;
};
const createOptionActions = options => (dispatch, query, state) => {
const obj = {};
forin(options, key => {
const name = fromCamels(key, '_').toUpperCase();
obj[`SET_${name}`] = action => {
try {
state.options[key] = action.value;
} catch (e) {
// nope, failed
}
// we successfully set the value of this option
dispatch(`DID_SET_${name}`, { value: state.options[key] });
};
});
return obj;
};
const createOptionQueries = options => state => {
const obj = {};
forin(options, key => {
obj[`GET_${fromCamels(key, '_').toUpperCase()}`] = action =>
state.options[key];
});
return obj;
};
const InteractionMethod = {
API: 1,
DROP: 2,
BROWSE: 3,
PASTE: 4,
NONE: 5
};
const getUniqueId = () =>
Math.random()
.toString(36)
.substr(2, 9);
const arrayRemove = (arr, index) => arr.splice(index, 1);
const on = () => {
const listeners = [];
const off = (event, cb) => {
arrayRemove(
listeners,
listeners.findIndex(
listener => listener.event === event && (listener.cb === cb || !cb)
)
);
};
return {
fire: (event, ...args) => {
listeners
.filter(listener => listener.event === event)
.map(listener => listener.cb)
.forEach(cb => {
setTimeout(() => {
cb(...args);
}, 0);
});
},
on: (event, cb) => {
listeners.push({ event, cb });
},
onOnce: (event, cb) => {
listeners.push({
event,
cb: (...args) => {
off(event, cb);
cb(...args);
}
});
},
off
};
};
const copyObjectPropertiesToObject = (src, target, excluded) => {
Object.getOwnPropertyNames(src)
.filter(property => !excluded.includes(property))
.forEach(key =>
Object.defineProperty(
target,
key,
Object.getOwnPropertyDescriptor(src, key)
)
);
};
const PRIVATE = [
'fire',
'process',
'revert',
'load',
'on',
'off',
'onOnce',
'retryLoad',
'extend',
'archive',
'archived',
'release',
'released',
'requestProcessing',
'freeze'
];
const createItemAPI = item => {
const api = {};
copyObjectPropertiesToObject(item, api, PRIVATE);
return api;
};
const removeReleasedItems = items => {
items.forEach((item, index) => {
if (item.released) {
arrayRemove(items, index);
}
});
};
const ItemStatus = {
INIT: 1,
IDLE: 2,
PROCESSING_QUEUED: 9,
PROCESSING: 3,
PROCESSING_COMPLETE: 5,
PROCESSING_ERROR: 6,
PROCESSING_REVERT_ERROR: 10,
LOADING: 7,
LOAD_ERROR: 8
};
const FileOrigin = {
INPUT: 1,
LIMBO: 2,
LOCAL: 3
};
const getNonNumeric = str => /[^0-9]+/.exec(str);
const getDecimalSeparator = () => getNonNumeric((1.1).toLocaleString())[0];
const getThousandsSeparator = () => {
// Added for browsers that do not return the thousands separator (happend on native browser Android 4.4.4)
// We check against the normal toString output and if they're the same return a comma when decimal separator is a dot
const decimalSeparator = getDecimalSeparator();
const thousandsStringWithSeparator = (1000.0).toLocaleString();
const thousandsStringWithoutSeparator = (1000.0).toString();
if (thousandsStringWithSeparator !== thousandsStringWithoutSeparator) {
return getNonNumeric(thousandsStringWithSeparator)[0];
}
return decimalSeparator === '.' ? ',' : '.';
};
const Type = {
BOOLEAN: 'boolean',
INT: 'int',
NUMBER: 'number',
STRING: 'string',
ARRAY: 'array',
OBJECT: 'object',
FUNCTION: 'function',
ACTION: 'action',
SERVER_API: 'serverapi',
REGEX: 'regex'
};
// all registered filters
const filters = [];
// loops over matching filters and passes options to each filter, returning the mapped results
const applyFilterChain = (key, value, utils) =>
new Promise((resolve, reject) => {
// find matching filters for this key
const matchingFilters = filters.filter(f => f.key === key).map(f => f.cb);
// resolve now
if (matchingFilters.length === 0) {
resolve(value);
return;
}
// first filter to kick things of
const initialFilter = matchingFilters.shift();
// chain filters
matchingFilters
.reduce(
// loop over promises passing value to next promise
(current, next) => current.then(value => next(value, utils)),
// call initial filter, will return a promise
initialFilter(value, utils)
// all executed
)
.then(value => resolve(value))
.catch(error => reject(error));
});
const applyFilters = (key, value, utils) =>
filters.filter(f => f.key === key).map(f => f.cb(value, utils));
// adds a new filter to the list
const addFilter = (key, cb) => filters.push({ key, cb });
const extendDefaultOptions = additionalOptions =>
Object.assign(defaultOptions, additionalOptions);
const getOptions = () => ({ ...defaultOptions });
const setOptions = opts => {
forin(opts, (key, value) => {
// key does not exist, so this option cannot be set
if (!defaultOptions[key]) {
return;
}
defaultOptions[key][0] = getValueByType(
value,
defaultOptions[key][0],
defaultOptions[key][1]
);
});
};
// default options on app
const defaultOptions = {
// the id to add to the root element
id: [null, Type.STRING],
// input field name to use
name: ['filepond', Type.STRING],
// disable the field
disabled: [false, Type.BOOLEAN],
// classname to put on wrapper
className: [null, Type.STRING],
// is the field required
required: [false, Type.BOOLEAN],
// Allow media capture when value is set
captureMethod: [null, Type.STRING],
// - "camera", "microphone" or "camcorder",
// - Does not work with multiple on apple devices
// - If set, acceptedFileTypes must be made to match with media wildcard "image/*", "audio/*" or "video/*"
// Feature toggles
allowDrop: [true, Type.BOOLEAN], // Allow dropping of files
allowBrowse: [true, Type.BOOLEAN], // Allow browsing the file system
allowPaste: [true, Type.BOOLEAN], // Allow pasting files
allowMultiple: [false, Type.BOOLEAN], // Allow multiple files (disabled by default, as multiple attribute is also required on input to allow multiple)
allowReplace: [true, Type.BOOLEAN], // Allow dropping a file on other file to replace it (only works when multiple is set to false)
allowRevert: [true, Type.BOOLEAN], // Allows user to revert file upload
// Revert mode
forceRevert: [false, Type.BOOLEAN], // Set to 'force' to require the file to be reverted before removal
// Input requirements
maxFiles: [null, Type.INT], // Max number of files
checkValidity: [false, Type.BOOLEAN], // Enables custom validity messages
// Where to put file
itemInsertLocationFreedom: [true, Type.BOOLEAN], // Set to false to always add items to begin or end of list
itemInsertLocation: ['before', Type.STRING], // Default index in list to add items that have been dropped at the top of the list
itemInsertInterval: [75, Type.INT],
// Drag 'n Drop related
dropOnPage: [false, Type.BOOLEAN], // Allow dropping of files anywhere on page (prevents browser from opening file if dropped outside of Up)
dropOnElement: [true, Type.BOOLEAN], // Drop needs to happen on element (set to false to also load drops outside of Up)
dropValidation: [false, Type.BOOLEAN], // Enable or disable validating files on drop
ignoredFiles: [['.ds_store', 'thumbs.db', 'desktop.ini'], Type.ARRAY],
// Upload related
instantUpload: [true, Type.BOOLEAN], // Should upload files immidiately on drop
maxParallelUploads: [2, Type.INT], // Maximum files to upload in parallel
// The server api end points to use for uploading (see docs)
server: [null, Type.SERVER_API],
// Labels and status messages
labelDecimalSeparator: [getDecimalSeparator(), Type.STRING], // Default is locale separator
labelThousandsSeparator: [getThousandsSeparator(), Type.STRING], // Default is locale separator
labelIdle: [
'Drag & Drop your files or <span class="filepond--label-action">Browse</span>',
Type.STRING
],
labelInvalidField: ['Field contains invalid files', Type.STRING],
labelFileWaitingForSize: ['Waiting for size', Type.STRING],
labelFileSizeNotAvailable: ['Size not available', Type.STRING],
labelFileCountSingular: ['file in list', Type.STRING],
labelFileCountPlural: ['files in list', Type.STRING],
labelFileLoading: ['Loading', Type.STRING],
labelFileAdded: ['Added', Type.STRING], // assistive only
labelFileLoadError: ['Error during load', Type.STRING],
labelFileRemoved: ['Removed', Type.STRING], // assistive only
labelFileRemoveError: ['Error during remove', Type.STRING],
labelFileProcessing: ['Uploading', Type.STRING],
labelFileProcessingComplete: ['Upload complete', Type.STRING],
labelFileProcessingAborted: ['Upload cancelled', Type.STRING],
labelFileProcessingError: ['Error during upload', Type.STRING],
labelFileProcessingRevertError: ['Error during revert', Type.STRING],
labelTapToCancel: ['tap to cancel', Type.STRING],
labelTapToRetry: ['tap to retry', Type.STRING],
labelTapToUndo: ['tap to undo', Type.STRING],
labelButtonRemoveItem: ['Remove', Type.STRING],
labelButtonAbortItemLoad: ['Abort', Type.STRING],
labelButtonRetryItemLoad: ['Retry', Type.STRING],
labelButtonAbortItemProcessing: ['Cancel', Type.STRING],
labelButtonUndoItemProcessing: ['Undo', Type.STRING],
labelButtonRetryItemProcessing: ['Retry', Type.STRING],
labelButtonProcessItem: ['Upload', Type.STRING],
// make sure width and height plus viewpox are even numbers so icons are nicely centered
iconRemove: [
'<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg"><path d="M11.586 13l-2.293 2.293a1 1 0 0 0 1.414 1.414L13 14.414l2.293 2.293a1 1 0 0 0 1.414-1.414L14.414 13l2.293-2.293a1 1 0 0 0-1.414-1.414L13 11.586l-2.293-2.293a1 1 0 0 0-1.414 1.414L11.586 13z" fill="currentColor" fill-rule="nonzero"/></svg>',
Type.STRING
],
iconProcess: [
'<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg"><path d="M14 10.414v3.585a1 1 0 0 1-2 0v-3.585l-1.293 1.293a1 1 0 0 1-1.414-1.415l3-3a1 1 0 0 1 1.414 0l3 3a1 1 0 0 1-1.414 1.415L14 10.414zM9 18a1 1 0 0 1 0-2h8a1 1 0 0 1 0 2H9z" fill="currentColor" fill-rule="evenodd"/></svg>',
Type.STRING
],
iconRetry: [
'<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg"><path d="M10.81 9.185l-.038.02A4.997 4.997 0 0 0 8 13.683a5 5 0 0 0 5 5 5 5 0 0 0 5-5 1 1 0 0 1 2 0A7 7 0 1 1 9.722 7.496l-.842-.21a.999.999 0 1 1 .484-1.94l3.23.806c.535.133.86.675.73 1.21l-.804 3.233a.997.997 0 0 1-1.21.73.997.997 0 0 1-.73-1.21l.23-.928v-.002z" fill="currentColor" fill-rule="nonzero"/></svg>',
Type.STRING
],
iconUndo: [
'<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg"><path d="M9.185 10.81l.02-.038A4.997 4.997 0 0 1 13.683 8a5 5 0 0 1 5 5 5 5 0 0 1-5 5 1 1 0 0 0 0 2A7 7 0 1 0 7.496 9.722l-.21-.842a.999.999 0 1 0-1.94.484l.806 3.23c.133.535.675.86 1.21.73l3.233-.803a.997.997 0 0 0 .73-1.21.997.997 0 0 0-1.21-.73l-.928.23-.002-.001z" fill="currentColor" fill-rule="nonzero"/></svg>',
Type.STRING
],
iconDone: [
'<svg width="26" height="26" viewBox="0 0 26 26" xmlns="http://www.w3.org/2000/svg"><path d="M18.293 9.293a1 1 0 0 1 1.414 1.414l-7.002 7a1 1 0 0 1-1.414 0l-3.998-4a1 1 0 1 1 1.414-1.414L12 15.586l6.294-6.293z" fill="currentColor" fill-rule="nonzero"/></svg>',
Type.STRING
],
// event handlers
oninit: [null, Type.FUNCTION],
onwarning: [null, Type.FUNCTION],
onerror: [null, Type.FUNCTION],
onactivatefile: [null, Type.FUNCTION],
onaddfilestart: [null, Type.FUNCTION],
onaddfileprogress: [null, Type.FUNCTION],
onaddfile: [null, Type.FUNCTION],
onprocessfilestart: [null, Type.FUNCTION],
onprocessfileprogress: [null, Type.FUNCTION],
onprocessfileabort: [null, Type.FUNCTION],
onprocessfilerevert: [null, Type.FUNCTION],
onprocessfile: [null, Type.FUNCTION],
onprocessfiles: [null, Type.FUNCTION],
onremovefile: [null, Type.FUNCTION],
onpreparefile: [null, Type.FUNCTION],
onupdatefiles: [null, Type.FUNCTION],
// hooks
beforeDropFile: [null, Type.FUNCTION],
beforeAddFile: [null, Type.FUNCTION],
beforeRemoveFile: [null, Type.FUNCTION],
// styles
stylePanelLayout: [null, Type.STRING], // null 'integrated', 'compact', 'circle'
stylePanelAspectRatio: [null, Type.STRING], // null or '3:2' or 1
styleItemPanelAspectRatio: [null, Type.STRING],
styleButtonRemoveItemPosition: ['left', Type.STRING],
styleButtonProcessItemPosition: ['right', Type.STRING],
styleLoadIndicatorPosition: ['right', Type.STRING],
styleProgressIndicatorPosition: ['right', Type.STRING],
// custom initial files array
files: [[], Type.ARRAY]
};
const getItemByQuery = (items, query) => {
// just return first index
if (isEmpty(query)) {
return items[0] || null;
}
// query is index
if (isInt(query)) {
return items[query] || null;
}
// if query is item, get the id
if (typeof query === 'object') {
query = query.id;
}
// assume query is a string and return item by id
return items.find(item => item.id === query) || null;
};
const getNumericAspectRatioFromString = aspectRatio => {
if (isEmpty(aspectRatio)) {
return aspectRatio;
}
if (/:/.test(aspectRatio)) {
const parts = aspectRatio.split(':');
return parts[1] / parts[0];
}
return parseFloat(aspectRatio);
};
const getActiveItems = items => items.filter(item => !item.archived);
const Status = {
EMPTY: 0,
IDLE: 1, // waiting
ERROR: 2, // a file is in error state
BUSY: 3, // busy processing or loading
READY: 4 // all files uploaded
};
const ITEM_ERROR = [
ItemStatus.LOAD_ERROR,
ItemStatus.PROCESSING_ERROR,
ItemStatus.PROCESSING_REVERT_ERROR
];
const ITEM_BUSY = [
ItemStatus.LOADING,
ItemStatus.PROCESSING,
ItemStatus.PROCESSING_QUEUED,
ItemStatus.INIT
];
const ITEM_READY = [ItemStatus.PROCESSING_COMPLETE];
const isItemInErrorState = item => ITEM_ERROR.includes(item.status);
const isItemInBusyState = item => ITEM_BUSY.includes(item.status);
const isItemInReadyState = item => ITEM_READY.includes(item.status);
const queries = state => ({
GET_STATUS: () => {
const items = getActiveItems(state.items);
const { EMPTY, ERROR, BUSY, IDLE, READY } = Status;
if (items.length === 0) return EMPTY;
if (items.some(isItemInErrorState)) return ERROR;
if (items.some(isItemInBusyState)) return BUSY;
if (items.some(isItemInReadyState)) return READY;
return IDLE;
},
GET_ITEM: query => getItemByQuery(state.items, query),
GET_ACTIVE_ITEM: query => getItemByQuery(getActiveItems(state.items), query),
GET_ACTIVE_ITEMS: query => getActiveItems(state.items),
GET_ITEMS: query => state.items,
GET_ITEM_NAME: query => {
const item = getItemByQuery(state.items, query);
return item ? item.filename : null;
},
GET_ITEM_SIZE: query => {
const item = getItemByQuery(state.items, query);
return item ? item.fileSize : null;
},
GET_STYLES: () =>
Object.keys(state.options)
.filter(key => /^style/.test(key))
.map(option => ({
name: option,
value: state.options[option]
})),
GET_PANEL_ASPECT_RATIO: () => {
const isShapeCircle = /circle/.test(state.options.stylePanelLayout);
const aspectRatio = isShapeCircle
? 1
: getNumericAspectRatioFromString(state.options.stylePanelAspectRatio);
return aspectRatio;
},
GET_ITEM_PANEL_ASPECT_RATIO: () => state.options.styleItemPanelAspectRatio,
GET_ITEMS_BY_STATUS: status =>
getActiveItems(state.items).filter(item => item.status === status),
GET_TOTAL_ITEMS: () => getActiveItems(state.items).length,
IS_ASYNC: () =>
isObject(state.options.server) &&
(isObject(state.options.server.process) ||
isFunction(state.options.server.process))
});
const hasRoomForItem = state => {
const count = getActiveItems(state.items).length;
// if cannot have multiple items, to add one item it should currently not contain items
if (!state.options.allowMultiple) {
return count === 0;
}
// if allows multiple items, we check if a max item count has been set, if not, there's no limit
const maxFileCount = state.options.maxFiles;
if (maxFileCount === null) {
return true;
}
// we check if the current count is smaller than the max count, if so, another file can still be added
if (count < maxFileCount) {
return true;
}
// no more room for another file
return false;
};
const limit = (value, min, max) => Math.max(Math.min(max, value), min);
const arrayInsert = (arr, index, item) => arr.splice(index, 0, item);
const insertItem = (items, item, index) => {
if (isEmpty(item)) {
return null;
}
// if index is undefined, append
if (typeof index === 'undefined') {
items.push(item);
return item;
}
// limit the index to the size of the items array
index = limit(index, 0, items.length);
// add item to array
arrayInsert(items, index, item);
// expose
return item;
};
const isBase64DataURI = str =>
/^\s*data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@\/?%\s]*)\s*$/i.test(
str
);
const getFilenameFromURL = url =>
url
.split('/')
.pop()
.split('?')
.shift();
const getExtensionFromFilename = name => name.split('.').pop();
const guesstimateExtension = type => {
// if no extension supplied, exit here
if (typeof type !== 'string') {
return '';
}
// get subtype
const subtype = type.split('/').pop();
// is svg subtype
if (/svg/.test(subtype)) {
return 'svg';
}
if (/zip|compressed/.test(subtype)) {
return 'zip';
}
if (/plain/.test(subtype)) {
return 'txt';
}
if (/msword/.test(subtype)) {
return 'doc';
}
// if is valid subtype
if (/[a-z]+/.test(subtype)) {
// always use jpg extension
if (subtype === 'jpeg') {
return 'jpg';
}
// return subtype
return subtype;
}
return '';
};
const leftPad = (value, padding = '') =>
(padding + value).slice(-padding.length);
const getDateString = (date = new Date()) =>
`${date.getFullYear()}-${leftPad(date.getMonth() + 1, '00')}-${leftPad(
date.getDate(),
'00'
)}_${leftPad(date.getHours(), '00')}-${leftPad(
date.getMinutes(),
'00'
)}-${leftPad(date.getSeconds(), '00')}`;
const getFileFromBlob = (blob, filename, type = null, extension = null) => {
const file =
typeof type === 'string'
? blob.slice(0, blob.size, type)
: blob.slice(0, blob.size, blob.type);
file.lastModifiedDate = new Date();
// if blob has name property, use as filename if no filename supplied
if (!isString(filename)) {
filename = getDateString();
}
// if filename supplied but no extension and filename has extension
if (filename && extension === null && getExtensionFromFilename(filename)) {
file.name = filename;
} else {
extension = extension || guesstimateExtension(file.type);
file.name = filename + (extension ? '.' + extension : '');
}
return file;
};
const getBlobBuilder = () => {
return (window.BlobBuilder =
window.BlobBuilder ||
window.WebKitBlobBuilder ||
window.MozBlobBuilder ||
window.MSBlobBuilder);
};
const createBlob = (arrayBuffer, mimeType) => {
const BB = getBlobBuilder();
if (BB) {
const bb = new BB();
bb.append(arrayBuffer);
return bb.getBlob(mimeType);
}
return new Blob([arrayBuffer], {
type: mimeType
});
};
const getBlobFromByteStringWithMimeType = (byteString, mimeType) => {
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return createBlob(ab, mimeType);
};
const getMimeTypeFromBase64DataURI = dataURI => {
return (/^data:(.+);/.exec(dataURI) || [])[1] || null;
};
const getBase64DataFromBase64DataURI = dataURI => {
// get data part of string (remove data:image/jpeg...,)
const data = dataURI.split(',')[1];
// remove any whitespace as that causes InvalidCharacterError in IE
return data.replace(/\s/g, '');
};
const getByteStringFromBase64DataURI = dataURI => {
return atob(getBase64DataFromBase64DataURI(dataURI));
};
const getBlobFromBase64DataURI = dataURI => {
const mimeType = getMimeTypeFromBase64DataURI(dataURI);
const byteString = getByteStringFromBase64DataURI(dataURI);
return getBlobFromByteStringWithMimeType(byteString, mimeType);
};
const getFileFromBase64DataURI = (dataURI, filename, extension) => {
return getFileFromBlob(
getBlobFromBase64DataURI(dataURI),
filename,
null,
extension
);
};
const getFileNameFromHeader = header => {
// test if is content disposition header, if not exit
if (!/^content-disposition:/i.test(header)) return null;
// get filename parts
const matches = header
.split(/filename=|filename\*=.+''/)
.splice(1)
.map(name => name.trim().replace(/^["']|[;"']{0,2}$/g, ''))
.filter(name => name.length);
return matches.length ? decodeURI(matches[matches.length - 1]) : null;
};
const getFileSizeFromHeader = header => {
if (/content-length:/i.test(header)) {
const size = header.match(/[0-9]+/)[0];
return size ? parseInt(size, 10) : null;
}
return null;
};
const getTranfserIdFromHeader = header => {
if (/x-content-transfer-id:/i.test(header)) {
const id = (header.split(':')[1] || '').trim();
return id || null;
}
return null;
};
const getFileInfoFromHeaders = headers => {
const info = {
source: null,
name: null,
size: null
};
const rows = headers.split('\n');
for (let header of rows) {
const name = getFileNameFromHeader(header);
if (name) {
info.name = name;
continue;
}
const size = getFileSizeFromHeader(header);
if (size) {
info.size = size;
continue;
}
const source = getTranfserIdFromHeader(header);
if (source) {
info.source = source;
continue;
}
}
return info;
};
const createFileLoader = fetchFn => {
const state = {
source: null,
complete: false,
progress: 0,
size: null,
timestamp: null,
duration: 0,
request: null
};
const getProgress = () => state.progress;
const abort = () => {
if (state.request && state.request.abort) {
state.request.abort();
}
};
// load source
const load = () => {
// get quick reference
const source = state.source;
api.fire('init', source);
// Load Files
if (source instanceof File) {
api.fire('load', source);
} else if (source instanceof Blob) {
// Load blobs, set default name to current date
api.fire('load', getFileFromBlob(source, source.name));
} else if (isBase64DataURI(source)) {
// Load base 64, set default name to current date
api.fire('load', getFileFromBase64DataURI(source));
} else {
// Deal as if is external URL, let's load it!
loadURL(source);
}
};
// loads a url
const loadURL = url => {
// is remote url and no fetch method supplied
if (!fetchFn) {
api.fire('error', {
type: 'error',
body: "Can't load URL",
code: 400
});
return;
}
// set request start
state.timestamp = Date.now();
// load file
state.request = fetchFn(
url,
response => {
// update duration
state.duration = Date.now() - state.timestamp;
// done!
state.complete = true;
// turn blob response into a file
if (response instanceof Blob) {
response = getFileFromBlob(
response,
response.name || getFilenameFromURL(url)
);
}
api.fire('load', response instanceof Blob ? response : response.body);
},
error => {
api.fire(
'error',
typeof error === 'string'
? {
type: 'error',
code: 0,
body: error
}
: error
);
},
(computable, current, total) => {
// collected some meta data already
if (total) {
state.size = total;
}
// update duration
state.duration = Date.now() - state.timestamp;
// if we can't compute progress, we're not going to fire progress events
if (!computable) {
state.progress = null;
return;
}
// update progress percentage
state.progress = current / total;
// expose
api.fire('progress', state.progress);
},
() => {
api.fire('abort');
},
response => {
const fileinfo = getFileInfoFromHeaders(
typeof response === 'string' ? response : response.headers
);
api.fire('meta', {
size: state.size || fileinfo.size,
filename: fileinfo.name,
source: fileinfo.source
});
}
);
};
const api = {
...on(),
setSource: source => (state.source = source),
getProgress, // file load progress
abort, // abort file load
load // start load
};
return api;
};
const isGet = method => /GET|HEAD/.test(method);
const sendRequest = (data, url, options) => {
const api = {
onheaders: () => {},
onprogress: () => {},
onload: () => {},
ontimeout: () => {},
onerror: () => {},
onabort: () => {},
abort: () => {
aborted = true;
xhr.abort();
}
};
// timeout identifier, only used when timeout is defined
let aborted = false;
let headersReceived = false;
// set default options
options = {
method: 'POST',
headers: {},
withCredentials: false,
...options
};
// encode url
url = encodeURI(url);
// if method is GET, add any received data to url
if (isGet(options.method) && data) {
url = `${url}${encodeURIComponent(
typeof data === 'string' ? data : JSON.stringify(data)
)}`;
}
// create request
const xhr = new XMLHttpRequest();
// progress of load
const process = isGet(options.method) ? xhr : xhr.upload;
process.onprogress = e => {
// no progress event when aborted ( onprogress is called once after abort() )
if (aborted) {
return;
}
api.onprogress(e.lengthComputable, e.loaded, e.total);
};
// tries to get header info to the app as fast as possible
xhr.onreadystatechange = () => {
// not interesting in these states ('unsent' and 'openend' as they don't give us any additional info)
if (xhr.readyState < 2) {
return;
}
// no server response
if (xhr.readyState === 4 && xhr.status === 0) {
return;
}
if (headersReceived) {
return;
}
headersReceived = true;
// we've probably received some useful data in response headers
api.onheaders(xhr);
};
// load successful
xhr.onload = () => {
// is classified as valid response
if (xhr.status >= 200 && xhr.status < 300) {
api.onload(xhr);
} else {
api.onerror(xhr);
}
};
// error during load
xhr.onerror = () => api.onerror(xhr);
// request aborted
xhr.onabort = () => {
aborted = true;
api.onabort();
};
// request timeout
xhr.ontimeout = () => api.ontimeout(xhr);
// open up open up!
xhr.open(options.method, url, true);
// set timeout if defined (do it after open so IE11 plays ball)
if (isInt(options.timeout)) {
xhr.timeout = options.timeout;
}
// add headers
Object.keys(options.headers).forEach(key => {
xhr.setRequestHeader(key, options.headers[key]);
});
// set type of response
if (options.responseType) {
xhr.responseType = options.responseType;
}
// set credentials
if (options.withCredentials) {
xhr.withCredentials = true;
}
// let's send our data
xhr.send(data);
return api;
};
const createResponse = (type, code, body, headers) => ({
type,
code,
body,
headers
});
const createTimeoutResponse = cb => xhr => {
cb(createResponse('error', 0, 'Timeout', xhr.getAllResponseHeaders()));
};
const createFetchFunction = (apiUrl = '', action) => {
// custom handler (should also handle file, load, error, progress and abort)
if (typeof action === 'function') {
return action;
}
// no action supplied
if (!action || !isString(action.url)) {
return null;
}
// set onload hanlder
const onload = action.onload || (res => res);
const onerror = action.onerror || (res => null);
// internal handler
return (url, load, error, progress, abort, headers) => {
// do local or remote request based on if the url is external
const request = sendRequest(url, apiUrl + action.url, {
...action,
responseType: 'blob'
});
request.onload = xhr => {
// get headers
const headers = xhr.getAllResponseHeaders();
// get filename
const filename =
getFileInfoFromHeaders(headers).name || getFilenameFromURL(url);
// create response
load(
createResponse(
'load',
xhr.status,
getFileFromBlob(onload(xhr.response), filename),
headers
)
);
};
request.onerror = xhr => {
error(
createResponse(
'error',
xhr.status,
onerror(xhr.response) || xhr.statusText,
xhr.getAllResponseHeaders()
)
);
};
request.onheaders = xhr => {
headers(
createResponse('headers', xhr.status, null, xhr.getAllResponseHeaders())
);
};
request.ontimeout = createTimeoutResponse(error);
request.onprogress = progress;
request.onabort = abort;
// should return request
return request;
};
};
/*
function signature:
(file, metadata, load, error, progress, abort) => {
return {
abort:() => {}
}
}
*/
const createProcessorFunction = (apiUrl = '', action, name) => {
// custom handler (should also handle file, load, error, progress and abort)
if (typeof action === 'function') {
return (...params) => action(name, ...params);
}
// no action supplied
if (!action || !isString(action.url)) {
return null;
}
// internal handler
return (file, metadata, load, error, progress, abort) => {
// set onload hanlder
const ondata = action.ondata || (fd => fd);
const onload = action.onload || (res => res);
const onerror = action.onerror || (res => null);
// no file received
if (!file) return;
// create formdata object
var formData = new FormData();
// add metadata under same name
if (isObject(metadata)) {
formData.append(name, JSON.stringify(metadata));
}
// Turn into an array of objects so no matter what the input, we can handle it the same way
(file instanceof Blob ? [{ name: null, file }] : file).forEach(item => {
formData.append(
name,
item.file,
item.name === null ? item.file.name : `${item.name}${item.file.name}`
);
});
// send request object
const request = sendRequest(ondata(formData), apiUrl + action.url, action);
request.onload = xhr => {
load(
createResponse(
'load',
xhr.status,
onload(xhr.response),
xhr.getAllResponseHeaders()
)
);
};
request.onerror = xhr => {
error(
createResponse(
'error',
xhr.status,
onerror(xhr.response) || xhr.statusText,
xhr.getAllResponseHeaders()
)
);
};
request.ontimeout = createTimeoutResponse(error);
request.onprogress = progress;
request.onabort = abort;
// should return request
return request;
};
};
/*
function signature:
(uniqueFileId, load, error) => { }
*/
const createRevertFunction = (apiUrl = '', action) => {
// is custom implementation
if (typeof action === 'function') {
return action;
}
// no action supplied, return stub function, interface will work, but file won't be removed
if (!action || !isString(action.url)) {
return (uniqueFileId, load) => load();
}
// set onload hanlder
const onload = action.onload || (res => res);
const onerror = action.onerror || (res => null);
// internal implementation
return (uniqueFileId, load, error) => {
const request = sendRequest(
uniqueFileId,
apiUrl + action.url,
action // contains method, headers and withCredentials properties
);
request.onload = xhr => {
load(
createResponse(
'load',
xhr.status,
onload(xhr.response),
xhr.getAllResponseHeaders()
)
);
};
request.onerror = xhr => {
error(
createResponse(
'error',
xhr.status,
onerror(xhr.response) || xhr.statusText,
xhr.getAllResponseHeaders()
)
);
};
request.ontimeout = createTimeoutResponse(error);
return request;
};
};
const getRandomNumber = (min = 0, max = 1) => min + Math.random() * (max - min);
const createPerceivedPerformanceUpdater = (
cb,
duration = 1000,
offset = 0,
tickMin = 25,
tickMax = 250
) => {
let timeout = null;
const start = Date.now();
const tick = () => {
let runtime = Date.now() - start;
let delay = getRandomNumber(tickMin, tickMax);
if (runtime + delay > duration) {
delay = runtime + delay - duration;
}
let progress = runtime / duration;
if (progress >= 1) {
cb(1);
return;
}
cb(progress);
timeout = setTimeout(tick, delay);
};
tick();
return {
clear: () => {
clearTimeout(timeout);
}
};
};
const createFileProcessor = processFn => {
const state = {
complete: false,
perceivedProgress: 0,
perceivedPerformanceUpdater: null,
progress: null,
timestamp: null,
perceivedDuration: 0,
duration: 0,
request: null,
response: null
};
const process = (file, metadata) => {
const progressFn = () => {
// we've not yet started the real download, stop here
// the request might not go through, for instance, there might be some server trouble
// if state.progress is null, the server does not allow computing progress and we show the spinner instead
if (state.duration === 0 || state.progress === null) {
return;
}
// as we're now processing, fire the progress event
api.fire('progress', api.getProgress());
};
const completeFn = () => {
state.complete = true;
api.fire('load-perceived', state.response.body);
};
// let's start processing
api.fire('start');
// set request start
state.timestamp = Date.now();
// create perceived performance progress indicator
state.perceivedPerformanceUpdater = createPerceivedPerformanceUpdater(
progress => {
state.perceivedProgress = progress;
state.perceivedDuration = Date.now() - state.timestamp;
progressFn();
// if fake progress is done, and a response has been received,
// and we've not yet called the complete method
if (
state.response &&
state.perceivedProgress === 1 &&
!state.complete
) {
// we done!
completeFn();
}
},
// random delay as in a list of files you start noticing
// files uploading at the exact same speed
getRandomNumber(750, 1500)
);
// remember request so we can abort it later
state.request = processFn(
// the file to process
file,
// the metadata to send along
metadata,
// callbacks (load, error, progress, abort)
// load expects the body to be a server id if
// you want to make use of revert
response => {
// we put the response in state so we can access
// it outside of this method
state.response = isObject(response)
? response
: {
type: 'load',
code: 200,
body: `${response}`,
headers: {}
};
// update duration
state.duration = Date.now() - state.timestamp;
// force progress to 1 as we're now done
state.progress = 1;
// actual load is done let's share results
api.fire('load', state.response.body);
// we are really done
// if perceived progress is 1 ( wait for perceived progress to complete )
// or if server does not support progress ( null )
if (state.perceivedProgress === 1) {
completeFn();
}
},
// error is expected to be an object with type, code, body
error => {
// cancel updater
state.perceivedPerformanceUpdater.clear();
// update others about this error
api.fire(
'error',
isObject(error)
? error
: {
type: 'error',
code: 0,
body: `${error}`
}
);
},
// actual processing progress
(computable, current, total) => {
// update actual duration
state.duration = Date.now() - state.timestamp;
// update actual progress
state.progress = computable ? current / total : null;
progressFn();
},
// abort does not expect a value
() => {
// stop updater
state.perceivedPerformanceUpdater.clear();
// fire the abort event so we can switch visuals
api.fire('abort', state.response ? state.response.body : null);
}
);
};
const abort = () => {
// no request running, can't abort
if (!state.request) return;
// stop updater
state.perceivedPerformanceUpdater.clear();
// abort actual request
state.request.abort();
// if has response object, we've completed the request
state.complete = true;
};
const reset = () => {
abort();
state.complete = false;
state.perceivedProgress = 0;
state.progress = 0;
state.timestamp = null;
state.perceivedDuration = 0;
state.duration = 0;
state.request = null;
state.response = null;
};
const getProgress = () =>
state.progress ? Math.min(state.progress, state.perceivedProgress) : null;
const getDuration = () => Math.min(state.duration, state.perceivedDuration);
const api = {
...on(),
process, // start processing file
abort, // abort active process request
getProgress,
getDuration,
reset
};
return api;
};
const getFilenameWithoutExtension = name =>
name.substr(0, name.lastIndexOf('.')) || name;
const createFileStub = source => {
let data = [source.name, source.size, source.type];
// is blob or base64, then we need to set the name
if (source instanceof Blob || isBase64DataURI(source)) {
data[0] = source.name || getDateString();
} else if (isBase64DataURI(source)) {
// if is base64 data uri we need to determine the average size and type
data[1] = source.length;
data[2] = getMimeTypeFromBase64DataURI(source);
} else if (isString(source)) {
// url
data[0] = getFilenameFromURL(source);
data[1] = 0;
data[2] = 'application/octet-stream';
}
return {
name: data[0],
size: data[1],
type: data[2]
};
};
const isFile = value =>
!!(value instanceof File || (value instanceof Blob && value.name));
const deepCloneObject = src => {
if (!isObject(src)) return src;
const target = isArray(src) ? [] : {};
for (const key in src) {
if (!src.hasOwnProperty(key)) continue;
const v = src[key];
target[key] = v && isObject(v) ? deepCloneObject(v) : v;
}
return target;
};
const createItem = (origin = null, serverFileReference = null, file = null) => {
// unique id for this item, is used to identify the item across views
const id = getUniqueId();
/**
* Internal item state
*/
const state = {
// is archived
archived: false,
// if is frozen, no longer fires events
frozen: false,
// removed from view
released: false,
// original source
source: null,
// file model reference
file,
// id of file on server
serverFileReference,
// current item status
status: serverFileReference
? ItemStatus.PROCESSING_COMPLETE
: ItemStatus.INIT,
// active processes
activeLoader: null,
activeProcessor: null
};
// callback used when abort processing is called to link back to the resolve method
let abortProcessingRequestComplete = null;
/**
* Externally added item metadata
*/
const metadata = {};
// item data
const setStatus = status => (state.status = status);
// fire event unless the item has been archived
const fire = (event, ...params) => {
if (state.released || state.frozen) return;
api.fire(event, ...params);
};
// file data
const getFileExtension = () => getExtensionFromFilename(state.file.name);
const getFileType = () => state.file.type;
const getFileSize = () => state.file.size;
const getFile = () => state.file;
//
// logic to load a file
//
const load = (source, loader, onload) => {
// remember the original item source
state.source = source;
// file stub is already there
if (state.file) {
fire('load-skip');
return;
}
// set a stub file object while loading the actual data
state.file = createFileStub(source);
// starts loading
loader.on('init', () => {
fire('load-init');
});
// we'eve received a size indication, let's update the stub
loader.on('meta', meta => {
// set size of file stub
state.file.size = meta.size;
// set name of file stub
state.file.filename = meta.filename;
// if has received source, we done
if (meta.source) {
origin = FileOrigin.LIMBO;
state.serverFileReference = meta.source;
state.status = ItemStatus.PROCESSING_COMPLETE;
}
// size has been updated
fire('load-meta');
});
// the file is now loading we need to update the progress indicators
loader.on('progress', progress => {
setStatus(ItemStatus.LOADING);
fire('load-progress', progress);
});
// an error was thrown while loading the file, we need to switch to error state
loader.on('error', error => {
setStatus(ItemStatus.LOAD_ERROR);
fire('load-request-error', error);
});
// user or another process aborted the file load (cannot retry)
loader.on('abort', () => {
setStatus(ItemStatus.INIT);
fire('load-abort');
});
// done loading
loader.on('load', file => {
// as we've now loaded the file the loader is no longer required
state.activeLoader = null;
// called when file has loaded succesfully
const success = result => {
// set (possibly) transformed file
state.file = isFile(result) ? result : state.file;
// file received
if (origin === FileOrigin.LIMBO && state.serverFileReference) {
setStatus(ItemStatus.PROCESSING_COMPLETE);
} else {
setStatus(ItemStatus.IDLE);
}
fire('load');
};
const error = result => {
// set original file
state.file = file;
fire('load-meta');
setStatus(ItemStatus.LOAD_ERROR);
fire('load-file-error', result);
};
// if we already have a server file reference, we don't need to call the onload method
if (state.serverFileReference) {
success(file);
return;
}
// no server id, let's give this file the full treatment
onload(file, success, error);
});
// set loader source data
loader.setSource(source);
// set as active loader
state.activeLoader = loader;
// load the source data
loader.load();
};
const retryLoad = () => {
if (!state.activeLoader) {
return;
}
state.activeLoader.load();
};
const abortLoad = () => {
if (state.activeLoader) {
state.activeLoader.abort();
return;
}
setStatus(ItemStatus.INIT);
fire('load-abort');
};
//
// logic to process a file
//
const process = (processor, onprocess) => {
// now processing
setStatus(ItemStatus.PROCESSING);
// reset abort callback
abortProcessingRequestComplete = null;
// if no file loaded we'll wait for the load event
if (!(state.file instanceof Blob)) {
api.on('load', () => {
process(processor, onprocess);
});
return;
}
// setup processor
processor.on('load', serverFileReference => {
// need this id to be able to revert the upload
state.serverFileReference = serverFileReference;
});
processor.on('load-perceived', serverFileReference => {
// no longer required
state.activeProcessor = null;
// need this id to be able to rever the upload
state.serverFileReference = serverFileReference;
setStatus(ItemStatus.PROCESSING_COMPLETE);
fire('process-complete', serverFileReference);
});
processor.on('start', () => {
fire('process-start');
});
processor.on('error', error => {
state.activeProcessor = null;
setStatus(ItemStatus.PROCESSING_ERROR);
fire('process-error', error);
});
processor.on('abort', serverFileReference => {
state.activeProcessor = null;
// if file was uploaded but processing was cancelled during perceived processor time store file reference
state.serverFileReference = serverFileReference;
setStatus(ItemStatus.IDLE);
fire('process-abort');
// has timeout so doesn't interfere with remove action
if (abortProcessingRequestComplete) {
abortProcessingRequestComplete();
}
});
processor.on('progress', progress => {
fire('process-progress', progress);
});
// when successfully transformed
const success = file => {
// if was archived in the mean time, don't process
if (state.archived) return;
// process file!
processor.process(file, { ...metadata });
};
// something went wrong during transform phase
const error = result => {};
// start processing the file
onprocess(state.file, success, error);
// set as active processor
state.activeProcessor = processor;
};
const requestProcessing = () => {
setStatus(ItemStatus.PROCESSING_QUEUED);
};
const abortProcessing = () =>
new Promise(resolve => {
if (!state.activeProcessor) {
setStatus(ItemStatus.IDLE);
fire('process-abort');
resolve();
return;
}
abortProcessingRequestComplete = () => {
resolve();
};
state.activeProcessor.abort();
});
//
// logic to revert a processed file
//
const revert = (revertFileUpload, forceRevert) =>
new Promise((resolve, reject) => {
// cannot revert without a server id for this process
if (state.serverFileReference === null) {
resolve();
return;
}
// revert the upload (fire and forget)
revertFileUpload(
state.serverFileReference,
() => {
// reset file server id as now it's no available on the server
state.serverFileReference = null;
resolve();
},
error => {
// don't set error state when reverting is optional, it will always resolve
if (!forceRevert) {
resolve();
return;
}
// oh no errors
setStatus(ItemStatus.PROCESSING_REVERT_ERROR);
fire('process-revert-error');
reject(error);
}
);
// fire event
setStatus(ItemStatus.IDLE);
fire('process-revert');
});
// exposed methods
const setMetadata = (key, value, silent) => {
const keys = key.split('.');
const root = keys[0];
const last = keys.pop();
let data = metadata;
keys.forEach(key => (data = data[key]));
// compare old value against new value, if they're the same, we're not updating
if (JSON.stringify(data[last]) === JSON.stringify(value)) return;
// update value
data[last] = value;
// don't fire update
if (silent) return;
// fire update
fire('metadata-update', {
key: root,
value: metadata[root]
});
};
const getMetadata = key => deepCloneObject(key ? metadata[key] : metadata);
const api = {
id: { get: () => id },
origin: { get: () => origin },
serverId: { get: () => state.serverFileReference },
status: { get: () => state.status },
filename: { get: () => state.file.name },
filenameWithoutExtension: {
get: () => getFilenameWithoutExtension(state.file.name)
},
fileExtension: { get: getFileExtension },
fileType: { get: getFileType },
fileSize: { get: getFileSize },
file: { get: getFile },
source: { get: () => state.source },
getMetadata,
setMetadata: (key, value, silent) => {
if (isObject(key)) {
const data = key;
Object.keys(data).forEach(key => {
setMetadata(key, data[key], value);
});
return key;
}
setMetadata(key, value, silent);
return value;
},
extend: (name, handler) => (itemAPI[name] = handler),
abortLoad,
retryLoad,
requestProcessing,
abortProcessing,
load,
process,
revert,
...on(),
freeze: () => (state.frozen = true),
release: () => (state.released = true),
released: { get: () => state.released },
archive: () => (state.archived = true),
archived: { get: () => state.archived }
};
// create it here instead of returning it instantly so we can extend it later
const itemAPI = createObject(api);
return itemAPI;
};
const getItemIndexByQuery = (items, query) => {
// just return first index
if (isEmpty(query)) {
return 0;
}
// invalid queries
if (!isString(query)) {
return -1;
}
// return item by id (or -1 if not found)
return items.findIndex(item => item.id === query);
};
const getItemById = (items, itemId) => {
const index = getItemIndexByQuery(items, itemId);
if (index < 0) {
return;
}
return items[index] || null;
};
const fetchLocal = (url, load, error, progress, abort, headers) => {
const request = sendRequest(null, url, {
method: 'GET',
responseType: 'blob'
});
request.onload = xhr => {
// get headers
const headers = xhr.getAllResponseHeaders();
// get filename
const filename =
getFileInfoFromHeaders(headers).name || getFilenameFromURL(url);
// create response
load(
createResponse(
'load',
xhr.status,
getFileFromBlob(xhr.response, filename),
headers
)
);
};
request.onerror = xhr => {
error(
createResponse(
'error',
xhr.status,
xhr.statusText,
xhr.getAllResponseHeaders()
)
);
};
request.onheaders = xhr => {
headers(
createResponse('headers', xhr.status, null, xhr.getAllResponseHeaders())
);
};
request.ontimeout = createTimeoutResponse(error);
request.onprogress = progress;
request.onabort = abort;
// should return request
return request;
};
const getDomainFromURL = url => {
if (url.indexOf('//') === 0) {
url = location.protocol + url;
}
return url
.toLowerCase()
.replace('blob:', '')
.replace(/([a-z])?:\/\//, '$1')
.split('/')[0];
};
const isExternalURL = url =>
(url.indexOf(':') > -1 || url.indexOf('//') > -1) &&
getDomainFromURL(location.href) !== getDomainFromURL(url);
const dynamicLabel = label => (...params) =>
isFunction(label) ? label(...params) : label;
const isMockItem = item => !isFile(item.file);
const listUpdated = (dispatch, state) => {
clearTimeout(state.listUpdateTimeout);
state.listUpdateTimeout = setTimeout(() => {
dispatch('DID_UPDATE_ITEMS', { items: getActiveItems(state.items) });
}, 0);
};
const optionalPromise = (fn, ...params) =>
new Promise(resolve => {
if (!fn) {
return resolve(true);
}
const result = fn(...params);
if (result == null) {
return resolve(true);
}
if (typeof result === 'boolean') {
return resolve(result);
}
if (typeof result.then === 'function') {
result.then(resolve);
}
});
const sortItems = (state, compare) => {
state.items.sort((a, b) => compare(createItemAPI(a), createItemAPI(b)));
};
// returns item based on state
const getItemByQueryFromState = (state, itemHandler) => ({
query,
success = () => {},
failure = () => {}
} = {}) => {
const item = getItemByQuery(state.items, query);
if (!item) {
failure({
error: createResponse('error', 0, 'Item not found'),
file: null
});
return;
}
itemHandler(item, success, failure);
};
const actions = (dispatch, query, state) => ({
/**
* Aborts all ongoing processes
*/
ABORT_ALL: () => {
getActiveItems(state.items).forEach(item => {
item.freeze();
item.abortLoad();
item.abortProcessing();
});
},
/**
* Sets initial files
*/
DID_SET_FILES: ({ value = [] }) => {
// map values to file objects
const files = value.map(file => ({
source: file.source ? file.source : file,
options: file.options
}));
// loop over files, if file is in list, leave it be, if not, remove
// test if items should be moved
let activeItems = getActiveItems(state.items);
activeItems.forEach(item => {
// if item not is in new value, remove
if (
!files.find(
file => file.source === item.source || file.source === item.file
)
) {
dispatch('REMOVE_ITEM', { query: item });
}
});
// add new files
activeItems = getActiveItems(state.items);
files.forEach((file, index) => {
// if file is already in list
if (
activeItems.find(
item => item.source === file.source || item.file === file.source
)
)
return;
// not in list, add
dispatch('ADD_ITEM', {
...file,
interactionMethod: InteractionMethod.NONE,
index
});
});
},
DID_UPDATE_ITEM_METADATA: ({ id }) => {
// if is called multiple times in close succession we combined all calls together to save resources
clearTimeout(state.itemUpdateTimeout);
state.itemUpdateTimeout = setTimeout(() => {
const item = getItemById(state.items, id);
// only revert and attempt to upload when we're uploading to a server
if (!query('IS_ASYNC')) {
// should we update the output data
applyFilterChain('SHOULD_PREPARE_OUTPUT', false, { item, query }).then(
shouldPrepareOutput => {
if (!shouldPrepareOutput) {
return;
}
dispatch(
'REQUEST_PREPARE_OUTPUT',
{
query: id,
item,
ready: file => {
dispatch('DID_PREPARE_OUTPUT', { id, file });
}
},
true
);
}
);
return;
}
// for async scenarios
const upload = () => {
// we push this forward a bit so the interface is updated correctly
setTimeout(() => {
dispatch('REQUEST_ITEM_PROCESSING', { query: id });
}, 32);
};
const revert = doUpload => {
item
.revert(
createRevertFunction(
state.options.server.url,
state.options.server.revert
),
query('GET_FORCE_REVERT')
)
.then(doUpload ? upload : () => {})
.catch(() => {});
};
const abort = doUpload => {
item.abortProcessing().then(doUpload ? upload : () => {});
};
// if we should re-upload the file immidiately
if (item.status === ItemStatus.PROCESSING_COMPLETE) {
return revert(state.options.instantUpload);
}
// if currently uploading, cancel upload
if (item.status === ItemStatus.PROCESSING) {
return abort(state.options.instantUpload);
}
if (state.options.instantUpload) {
upload();
}
}, 0);
},
SORT: ({ compare }) => {
sortItems(state, compare);
},
ADD_ITEMS: ({
items,
index,
interactionMethod,
success = () => {},
failure = () => {}
}) => {
let currentIndex = index;
if (index === -1 || typeof index === 'undefined') {
const insertLocation = query('GET_ITEM_INSERT_LOCATION');
const totalItems = query('GET_TOTAL_ITEMS');
currentIndex = insertLocation === 'before' ? 0 : totalItems;
}
const ignoredFiles = query('GET_IGNORED_FILES');
const isValidFile = source =>
isFile(source)
? !ignoredFiles.includes(source.name.toLowerCase())
: !isEmpty(source);
const validItems = items.filter(isValidFile);
const promises = validItems.map(
source =>
new Promise((resolve, reject) => {
dispatch('ADD_ITEM', {
interactionMethod,
source: source.source || source,
success: resolve,
failure: reject,
index: currentIndex++,
options: source.options || {}
});
})
);
Promise.all(promises)
.then(success)
.catch(failure);
},
/**
* @param source
* @param index
* @param interactionMethod
*/
ADD_ITEM: ({
source,
index = -1,
interactionMethod,
success = () => {},
failure = () => {},
options = {}
}) => {
// if no source supplied
if (isEmpty(source)) {
failure({
error: createResponse('error', 0, 'No source'),
file: null
});
return;
}
// filter out invalid file items, used to filter dropped directory contents
if (
isFile(source) &&
state.options.ignoredFiles.includes(source.name.toLowerCase())
) {
// fail silently
return;
}
// test if there's still room in the list of files
if (!hasRoomForItem(state)) {
// if multiple allowed, we can't replace
// or if only a single item is allowed but we're not allowed to replace it we exit
if (
state.options.allowMultiple ||
(!state.options.allowMultiple && !state.options.allowReplace)
) {
const error = createResponse('warning', 0, 'Max files');
dispatch('DID_THROW_MAX_FILES', {
source,
error
});
failure({ error, file: null });
return;
}
// let's replace the item
// id of first item we're about to remove
const item = getActiveItems(state.items)[0];
// if has been processed remove it from the server as well
if (
item.status === ItemStatus.PROCESSING_COMPLETE ||
item.status === ItemStatus.PROCESSING_REVERT_ERROR
) {
const forceRevert = query('GET_FORCE_REVERT');
item
.revert(
createRevertFunction(
state.options.server.url,
state.options.server.revert
),
forceRevert
)
.then(() => {
if (!forceRevert) return;
// try to add now
dispatch('ADD_ITEM', {
source,
index,
interactionMethod,
success,
failure,
options
});
})
.catch(() => {}); // no need to handle this catch state for now
if (forceRevert) return;
}
// remove first item as it will be replaced by this item
dispatch('REMOVE_ITEM', { query: item.id });
}
// where did the file originate
const origin =
options.type === 'local'
? FileOrigin.LOCAL
: options.type === 'limbo'
? FileOrigin.LIMBO
: FileOrigin.INPUT;
// create a new blank item
const item = createItem(
// where did this file come from
origin,
// an input file never has a server file reference
origin === FileOrigin.INPUT ? null : source,
// file mock data, if defined
options.file
);
// set initial meta data
Object.keys(options.metadata || {}).forEach(key => {
item.setMetadata(key, options.metadata[key]);
});
// created the item, let plugins add methods
applyFilters('DID_CREATE_ITEM', item, { query, dispatch });
// where to insert new items
const itemInsertLocation = query('GET_ITEM_INSERT_LOCATION');
// adjust index if is not allowed to pick location
if (!state.options.itemInsertLocationFreedom) {
index = itemInsertLocation === 'before' ? -1 : state.items.length;
}
// add item to list
insertItem(state.items, item, index);
// sort items in list
if (isFunction(itemInsertLocation) && source) {
sortItems(state, itemInsertLocation);
}
// get a quick reference to the item id
const id = item.id;
// observe item events
item.on('load-init', () => {
dispatch('DID_START_ITEM_LOAD', { id });
});
item.on('load-meta', () => {
dispatch('DID_UPDATE_ITEM_META', { id });
});
item.on('load-progress', progress => {
dispatch('DID_UPDATE_ITEM_LOAD_PROGRESS', { id, progress });
});
item.on('load-request-error', error => {
const mainStatus = dynamicLabel(state.options.labelFileLoadError)(error);
// is client error, no way to recover
if (error.code >= 400 && error.code < 500) {
dispatch('DID_THROW_ITEM_INVALID', {
id,
error,
status: {
main: mainStatus,
sub: `${error.code} (${error.body})`
}
});
// reject the file so can be dealt with through API
failure({ error, file: createItemAPI(item) });
return;
}
// is possible server error, so might be possible to retry
dispatch('DID_THROW_ITEM_LOAD_ERROR', {
id,
error,
status: {
main: mainStatus,
sub: state.options.labelTapToRetry
}
});
});
item.on('load-file-error', error => {
dispatch('DID_THROW_ITEM_INVALID', {
id,
error: error.status,
status: error.status
});
failure({ error: error.status, file: createItemAPI(item) });
});
item.on('load-abort', () => {
dispatch('REMOVE_ITEM', { query: id });
});
item.on('load-skip', () => {
dispatch('COMPLETE_LOAD_ITEM', {
query: id,
item,
data: {
source,
success
}
});
});
item.on('load', () => {
const handleAdd = shouldAdd => {
// no should not add this file
if (!shouldAdd) {
dispatch('REMOVE_ITEM', {
query: id
});
return;
}
// now interested in metadata updates
item.on('metadata-update', change => {
dispatch('DID_UPDATE_ITEM_METADATA', { id, change });
});
// let plugins decide if the output data should be prepared at this point
// means we'll do this and wait for idle state
applyFilterChain('SHOULD_PREPARE_OUTPUT', false, { item, query }).then(
shouldPrepareOutput => {
const loadComplete = () => {
dispatch('COMPLETE_LOAD_ITEM', {
query: id,
item,
data: {
source,
success
}
});
listUpdated(dispatch, state);
};
// exit
if (shouldPrepareOutput) {
// wait for idle state and then run PREPARE_OUTPUT
dispatch(
'REQUEST_PREPARE_OUTPUT',
{
query: id,
item,
ready: file => {
dispatch('DID_PREPARE_OUTPUT', { id, file });
loadComplete();
}
},
true
);
return;
}
loadComplete();
}
);
};
// item loaded, allow plugins to
// - read data (quickly)
// - add metadata
applyFilterChain('DID_LOAD_ITEM', item, { query, dispatch })
.then(() => {
optionalPromise(
query('GET_BEFORE_ADD_FILE'),
createItemAPI(item)
).then(handleAdd);
})
.catch(() => {
handleAdd(false);
});
});
item.on('process-start', () => {
dispatch('DID_START_ITEM_PROCESSING', { id });
});
item.on('process-progress', progress => {
dispatch('DID_UPDATE_ITEM_PROCESS_PROGRESS', { id, progress });
});
item.on('process-error', error => {
dispatch('DID_THROW_ITEM_PROCESSING_ERROR', {
id,
error,
status: {
main: dynamicLabel(state.options.labelFileProcessingError)(error),
sub: state.options.labelTapToRetry
}
});
});
item.on('process-revert-error', error => {
dispatch('DID_THROW_ITEM_PROCESSING_REVERT_ERROR', {
id,
error,
status: {
main: dynamicLabel(state.options.labelFileProcessingRevertError)(
error
),
sub: state.options.labelTapToRetry
}
});
});
item.on('process-complete', serverFileReference => {
dispatch('DID_COMPLETE_ITEM_PROCESSING', {
id,
error: null,
serverFileReference
});
});
item.on('process-abort', () => {
dispatch('DID_ABORT_ITEM_PROCESSING', { id });
});
item.on('process-revert', () => {
dispatch('DID_REVERT_ITEM_PROCESSING', { id });
});
// let view know the item has been inserted
dispatch('DID_ADD_ITEM', { id, index, interactionMethod });
listUpdated(dispatch, state);
// start loading the source
const { url, load, restore, fetch } = state.options.server || {};
item.load(
source,
// this creates a function that loads the file based on the type of file (string, base64, blob, file) and location of file (local, remote, limbo)
createFileLoader(
origin === FileOrigin.INPUT
? // input
isString(source) && isExternalURL(source)
? createFetchFunction(url, fetch) // remote url
: fetchLocal // local url
: // limbo or local
origin === FileOrigin.LIMBO
? createFetchFunction(url, restore) // limbo
: createFetchFunction(url, load) // local
),
// called when the file is loaded so it can be piped through the filters
(file, success, error) => {
// let's process the file
applyFilterChain('LOAD_FILE', file, { query })
.then(success)
.catch(error);
}
);
},
REQUEST_PREPARE_OUTPUT: ({ item, ready }) => {
// don't handle archived items, an item could have been archived (load aborted) while waiting to be prepared
if (item.archived) return;
// allow plugins to alter the file data
applyFilterChain('PREPARE_OUTPUT', item.file, { query, item }).then(
result => {
applyFilterChain('COMPLETE_PREPARE_OUTPUT', result, {
query,
item
}).then(result => {
// don't handle archived items, an item could have been archived (load aborted) while being prepared
if (item.archived) return;
// we done!
ready(result);
});
}
);
},
COMPLETE_LOAD_ITEM: ({ item, data }) => {
const { success, source } = data;
// sort items in list
const itemInsertLocation = query('GET_ITEM_INSERT_LOCATION');
if (isFunction(itemInsertLocation) && source) {
sortItems(state, itemInsertLocation);
}
// let interface know the item has loaded
dispatch('DID_LOAD_ITEM', {
id: item.id,
error: null,
serverFileReference: item.origin === FileOrigin.INPUT ? null : source
});
// item has been successfully loaded and added to the
// list of items so can now be safely returned for use
success(createItemAPI(item));
// if this is a local server file we need to show a different state
if (item.origin === FileOrigin.LOCAL) {
dispatch('DID_LOAD_LOCAL_ITEM', { id: item.id });
return;
}
// if is a temp server file we prevent async upload call here (as the file is already on the server)
if (item.origin === FileOrigin.LIMBO) {
dispatch('DID_COMPLETE_ITEM_PROCESSING', {
id: item.id,
error: null,
serverFileReference: source
});
return;
}
// id we are allowed to upload the file immidiately, lets do it
if (query('IS_ASYNC') && state.options.instantUpload) {
dispatch('REQUEST_ITEM_PROCESSING', { query: item.id });
}
},
RETRY_ITEM_LOAD: getItemByQueryFromState(state, item => {
// try loading the source one more time
item.retryLoad();
}),
REQUEST_ITEM_PROCESSING: getItemByQueryFromState(
state,
(item, success, failure) => {
// cannot be queued (or is already queued)
const itemCanBeQueuedForProcessing =
// waiting for something
item.status === ItemStatus.IDLE ||
// processing went wrong earlier
item.status === ItemStatus.PROCESSING_ERROR;
// not ready to be processed
if (!itemCanBeQueuedForProcessing) {
const process = () => {
setTimeout(() => {
dispatch('REQUEST_ITEM_PROCESSING', {
query: item,
success,
failure
});
}, 32);
};
// if already done processing or tried to revert but didn't work, try again
if (
item.status === ItemStatus.PROCESSING_COMPLETE ||
item.status === ItemStatus.PROCESSING_REVERT_ERROR
) {
item
.revert(
createRevertFunction(
state.options.server.url,
state.options.server.revert
),
query('GET_FORCE_REVERT')
)
.then(process)
.catch(() => {}); // don't continue with processing if something went wrong
} else if (item.status === ItemStatus.PROCESSING) {
item.abortProcessing().then(process);
}
return;
}
// already queued for processing
if (item.status === ItemStatus.PROCESSING_QUEUED) return;
item.requestProcessing();
dispatch('DID_REQUEST_ITEM_PROCESSING', { id: item.id });
dispatch('PROCESS_ITEM', { query: item, success, failure }, true);
}
),
PROCESS_ITEM: getItemByQueryFromState(state, (item, success, failure) => {
const maxParallelUploads = query('GET_MAX_PARALLEL_UPLOADS');
const totalCurrentUploads = query(
'GET_ITEMS_BY_STATUS',
ItemStatus.PROCESSING
).length;
// queue and wait till queue is freed up
if (totalCurrentUploads === maxParallelUploads) {
// queue for later processing
state.processingQueue.push({
id: item.id,
success,
failure
});
// stop it!
return;
}
// if was not queued or is already processing exit here
if (item.status === ItemStatus.PROCESSING) return;
const processNext = () => {
// process queueud items
const queueEntry = state.processingQueue.shift();
// no items left
if (!queueEntry) return;
// get item reference
const { id, success, failure } = queueEntry;
const itemReference = getItemByQuery(state.items, id);
// if item was archived while in queue, jump to next
if (!itemReference || itemReference.archived) {
processNext();
return;
}
// process queued item
dispatch('PROCESS_ITEM', { query: id, success, failure }, true);
};
// we done function
item.onOnce('process-complete', () => {
success(createItemAPI(item));
processNext();
// All items processed? No errors?
const allItemsProcessed =
query('GET_ITEMS_BY_STATUS', ItemStatus.PROCESSING_COMPLETE).length ===
state.items.length;
if (allItemsProcessed) {
dispatch('DID_COMPLETE_ITEM_PROCESSING_ALL');
}
});
// we error function
item.onOnce('process-error', error => {
failure({ error, file: createItemAPI(item) });
processNext();
});
// start file processing
item.process(
createFileProcessor(
createProcessorFunction(
state.options.server.url,
state.options.server.process,
state.options.name
)
),
// called when the file is about to be processed so it can be piped through the transform filters
(file, success, error) => {
// allow plugins to alter the file data
applyFilterChain('PREPARE_OUTPUT', file, { query, item })
.then(file => {
dispatch('DID_PREPARE_OUTPUT', { id: item.id, file });
success(file);
})
.catch(error);
}
);
}),
RETRY_ITEM_PROCESSING: getItemByQueryFromState(state, item => {
dispatch('REQUEST_ITEM_PROCESSING', { query: item });
}),
REQUEST_REMOVE_ITEM: getItemByQueryFromState(state, item => {
optionalPromise(query('GET_BEFORE_REMOVE_FILE'), createItemAPI(item)).then(
shouldRemove => {
if (!shouldRemove) {
return;
}
dispatch('REMOVE_ITEM', { query: item });
}
);
}),
RELEASE_ITEM: getItemByQueryFromState(state, item => {
item.release();
}),
REMOVE_ITEM: getItemByQueryFromState(state, (item, success) => {
const removeFromView = () => {
// get id reference
const id = item.id;
// archive the item, this does not remove it from the list
getItemById(state.items, id).archive();
// tell the view the item has been removed
dispatch('DID_REMOVE_ITEM', { error: null, id, item });
// now the list has been modified
listUpdated(dispatch, state);
// correctly removed
success(createItemAPI(item));
};
// if this is a local file and the server.remove function has been configured, send source there so dev can remove file from server
const server = state.options.server;
if (
item.origin === FileOrigin.LOCAL &&
server &&
isFunction(server.remove)
) {
dispatch('DID_START_ITEM_REMOVE', { id: item.id });
server.remove(
item.source,
() => removeFromView(),
status => {
dispatch('DID_THROW_ITEM_REMOVE_ERROR', {
id: item.id,
error: createResponse('error', 0, status, null),
status: {
main: dynamicLabel(state.options.labelFileRemoveError)(status),
sub: state.options.labelTapToRetry
}
});
}
);
} else {
removeFromView();
}
}),
ABORT_ITEM_LOAD: getItemByQueryFromState(state, item => {
item.abortLoad();
}),
ABORT_ITEM_PROCESSING: getItemByQueryFromState(state, item => {
// test if is already processed
if (item.serverId) {
dispatch('REVERT_ITEM_PROCESSING', { id: item.id });
return;
}
// abort
item.abortProcessing().then(() => {
const shouldRemove = state.options.instantUpload;
if (shouldRemove) {
dispatch('REMOVE_ITEM', { query: item.id });
}
});
}),
REQUEST_REVERT_ITEM_PROCESSING: getItemByQueryFromState(state, item => {
// not instant uploading, revert immidiately
if (!state.options.instantUpload) {
dispatch('REVERT_ITEM_PROCESSING', { query: item });
return;
}
// if we're instant uploading the file will also be removed if we revert,
// so if a before remove file hook is defined we need to run it now
const handleRevert = shouldRevert => {
if (!shouldRevert) return;
dispatch('REVERT_ITEM_PROCESSING', { query: item });
};
const fn = query('GET_BEFORE_REMOVE_FILE');
if (!fn) {
return handleRevert(true);
}
const requestRemoveResult = fn(createItemAPI(item));
if (requestRemoveResult == null) {
// undefined or null
return handleRevert(true);
}
if (typeof requestRemoveResult === 'boolean') {
return handleRevert(requestRemoveResult);
}
if (typeof requestRemoveResult.then === 'function') {
requestRemoveResult.then(handleRevert);
}
}),
REVERT_ITEM_PROCESSING: getItemByQueryFromState(state, item => {
item
.revert(
createRevertFunction(
state.options.server.url,
state.options.server.revert
),
query('GET_FORCE_REVERT')
)
.then(() => {
const shouldRemove = state.options.instantUpload || isMockItem(item);
if (shouldRemove) {
dispatch('REMOVE_ITEM', { query: item.id });
}
})
.catch(() => {});
}),
SET_OPTIONS: ({ options }) => {
forin(options, (key, value) => {
dispatch(`SET_${fromCamels(key, '_').toUpperCase()}`, { value });
});
}
});
const formatFilename = name => name;
const createElement$1 = tagName => {
return document.createElement(tagName);
};
const text = (node, value) => {
let textNode = node.childNodes[0];
if (!textNode) {
textNode = document.createTextNode(value);
node.appendChild(textNode);
} else if (value !== textNode.nodeValue) {
textNode.nodeValue = value;
}
};
const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => {
const angleInRadians = (((angleInDegrees % 360) - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians)
};
};
const describeArc = (x, y, radius, startAngle, endAngle, arcSweep) => {
const start = polarToCartesian(x, y, radius, endAngle);
const end = polarToCartesian(x, y, radius, startAngle);
return [
'M',
start.x,
start.y,
'A',
radius,
radius,
0,
arcSweep,
0,
end.x,
end.y
].join(' ');
};
const percentageArc = (x, y, radius, from, to) => {
let arcSweep = 1;
if (to > from && to - from <= 0.5) {
arcSweep = 0;
}
if (from > to && from - to >= 0.5) {
arcSweep = 0;
}
return describeArc(
x,
y,
radius,
Math.min(0.9999, from) * 360,
Math.min(0.9999, to) * 360,
arcSweep
);
};
const create = ({ root, props }) => {
// start at 0
props.spin = false;
props.progress = 0;
props.opacity = 0;
// svg
const svg = createElement('svg');
root.ref.path = createElement('path', {
'stroke-width': 2,
'stroke-linecap': 'round'
});
svg.appendChild(root.ref.path);
root.ref.svg = svg;
root.appendChild(svg);
};
const write = ({ root, props }) => {
if (props.opacity === 0) {
return;
}
if (props.align) {
root.element.dataset.align = props.align;
}
// get width of stroke
const ringStrokeWidth = parseInt(attr(root.ref.path, 'stroke-width'), 10);
// calculate size of ring
const size = root.rect.element.width * 0.5;
// ring state
let ringFrom = 0;
let ringTo = 0;
// now in busy mode
if (props.spin) {
ringFrom = 0;
ringTo = 0.5;
} else {
ringFrom = 0;
ringTo = props.progress;
}
// get arc path
const coordinates = percentageArc(
size,
size,
size - ringStrokeWidth,
ringFrom,
ringTo
);
// update progress bar
attr(root.ref.path, 'd', coordinates);
// hide while contains 0 value
attr(
root.ref.path,
'stroke-opacity',
props.spin || props.progress > 0 ? 1 : 0
);
};
const progressIndicator = createView({
tag: 'div',
name: 'progress-indicator',
ignoreRectUpdate: true,
ignoreRect: true,
create,
write,
mixins: {
apis: ['progress', 'spin', 'align'],
styles: ['opacity'],
animations: {
opacity: { type: 'tween', duration: 500 },
progress: {
type: 'spring',
stiffness: 0.95,
damping: 0.65,
mass: 10
}
}
}
});
const create$1 = ({ root, props }) => {
root.element.innerHTML = (props.icon || '') + `<span>${props.label}</span>`;
props.isDisabled = false;
};
const write$1 = ({ root, props }) => {
const { isDisabled } = props;
const shouldDisable = root.query('GET_DISABLED') || props.opacity === 0;
if (shouldDisable && !isDisabled) {
props.isDisabled = true;
attr(root.element, 'disabled', 'disabled');
} else if (!shouldDisable && isDisabled) {
props.isDisabled = false;
root.element.removeAttribute('disabled');
}
};
const fileActionButton = createView({
tag: 'button',
attributes: {
type: 'button'
},
ignoreRect: true,
ignoreRectUpdate: true,
name: 'file-action-button',
mixins: {
apis: ['label'],
styles: ['translateX', 'translateY', 'scaleX', 'scaleY', 'opacity'],
animations: {
scaleX: 'spring',
scaleY: 'spring',
translateX: 'spring',
translateY: 'spring',
opacity: { type: 'tween', duration: 250 }
},
listeners: true
},
create: create$1,
write: write$1
});
const toNaturalFileSize = (bytes, decimalSeparator = '.') => {
// nope, no negative byte sizes
bytes = Math.round(Math.abs(bytes));
// just bytes
if (bytes < 1000) {
return `${bytes} bytes`;
}
// kilobytes
if (bytes < MB) {
return `${Math.floor(bytes / KB)} KB`;
}
// megabytes
if (bytes < GB) {
return `${removeDecimalsWhenZero(bytes / MB, 1, decimalSeparator)} MB`;
}
// gigabytes
return `${removeDecimalsWhenZero(bytes / GB, 2, decimalSeparator)} GB`;
};
const KB = 1000;
const MB = 1000000;
const GB = 1000000000;
const removeDecimalsWhenZero = (value, decimalCount, separator) => {
return value
.toFixed(decimalCount)
.split('.')
.filter(part => part !== '0')
.join(separator);
};
const create$2 = ({ root, props }) => {
// filename
const fileName = createElement$1('span');
fileName.className = 'filepond--file-info-main';
// hide for screenreaders
// the file is contained in a fieldset with legend that contains the filename
// no need to read it twice
attr(fileName, 'aria-hidden', 'true');
root.appendChild(fileName);
root.ref.fileName = fileName;
// filesize
const fileSize = createElement$1('span');
fileSize.className = 'filepond--file-info-sub';
root.appendChild(fileSize);
root.ref.fileSize = fileSize;
// set initial values
text(fileSize, root.query('GET_LABEL_FILE_WAITING_FOR_SIZE'));
text(fileName, formatFilename(root.query('GET_ITEM_NAME', props.id)));
};
const updateFile = ({ root, props }) => {
text(
root.ref.fileSize,
toNaturalFileSize(root.query('GET_ITEM_SIZE', props.id))
);
text(
root.ref.fileName,
formatFilename(root.query('GET_ITEM_NAME', props.id))
);
};
const updateFileSizeOnError = ({ root, props }) => {
// if size is available don't fallback to unknown size message
if (isInt(root.query('GET_ITEM_SIZE', props.id))) {
return;
}
text(root.ref.fileSize, root.query('GET_LABEL_FILE_SIZE_NOT_AVAILABLE'));
};
const fileInfo = createView({
name: 'file-info',
ignoreRect: true,
ignoreRectUpdate: true,
write: createRoute({
DID_LOAD_ITEM: updateFile,
DID_UPDATE_ITEM_META: updateFile,
DID_THROW_ITEM_LOAD_ERROR: updateFileSizeOnError,
DID_THROW_ITEM_INVALID: updateFileSizeOnError
}),
didCreateView: root => {
applyFilters('CREATE_VIEW', { ...root, view: root });
},
create: create$2,
mixins: {
styles: ['translateX', 'translateY'],
animations: {
translateX: 'spring',
translateY: 'spring'
}
}
});
const toPercentage = value => Math.round(value * 100);
const create$3 = ({ root, props }) => {
// main status
const main = createElement$1('span');
main.className = 'filepond--file-status-main';
root.appendChild(main);
root.ref.main = main;
// sub status
const sub = createElement$1('span');
sub.className = 'filepond--file-status-sub';
root.appendChild(sub);
root.ref.sub = sub;
didSetItemLoadProgress({ root, action: { progress: null } });
};
const didSetItemLoadProgress = ({ root, action }) => {
const title =
action.progress === null
? root.query('GET_LABEL_FILE_LOADING')
: `${root.query('GET_LABEL_FILE_LOADING')} ${toPercentage(
action.progress
)}%`;
text(root.ref.main, title);
text(root.ref.sub, root.query('GET_LABEL_TAP_TO_CANCEL'));
};
const didSetItemProcessProgress = ({ root, action }) => {
const title =
action.progress === null
? root.query('GET_LABEL_FILE_PROCESSING')
: `${root.query('GET_LABEL_FILE_PROCESSING')} ${toPercentage(
action.progress
)}%`;
text(root.ref.main, title);
text(root.ref.sub, root.query('GET_LABEL_TAP_TO_CANCEL'));
};
const didRequestItemProcessing = ({ root }) => {
text(root.ref.main, root.query('GET_LABEL_FILE_PROCESSING'));
text(root.ref.sub, root.query('GET_LABEL_TAP_TO_CANCEL'));
};
const didAbortItemProcessing = ({ root }) => {
text(root.ref.main, root.query('GET_LABEL_FILE_PROCESSING_ABORTED'));
text(root.ref.sub, root.query('GET_LABEL_TAP_TO_RETRY'));
};
const didCompleteItemProcessing = ({ root }) => {
text(root.ref.main, root.query('GET_LABEL_FILE_PROCESSING_COMPLETE'));
text(root.ref.sub, root.query('GET_LABEL_TAP_TO_UNDO'));
};
const clear = ({ root }) => {
text(root.ref.main, '');
text(root.ref.sub, '');
};
const error = ({ root, action }) => {
text(root.ref.main, action.status.main);
text(root.ref.sub, action.status.sub);
};
const fileStatus = createView({
name: 'file-status',
ignoreRect: true,
ignoreRectUpdate: true,
write: createRoute({
DID_LOAD_ITEM: clear,
DID_REVERT_ITEM_PROCESSING: clear,
DID_REQUEST_ITEM_PROCESSING: didRequestItemProcessing,
DID_ABORT_ITEM_PROCESSING: didAbortItemProcessing,
DID_COMPLETE_ITEM_PROCESSING: didCompleteItemProcessing,
DID_UPDATE_ITEM_PROCESS_PROGRESS: didSetItemProcessProgress,
DID_UPDATE_ITEM_LOAD_PROGRESS: didSetItemLoadProgress,
DID_THROW_ITEM_LOAD_ERROR: error,
DID_THROW_ITEM_INVALID: error,
DID_THROW_ITEM_PROCESSING_ERROR: error,
DID_THROW_ITEM_PROCESSING_REVERT_ERROR: error,
DID_THROW_ITEM_REMOVE_ERROR: error
}),
didCreateView: root => {
applyFilters('CREATE_VIEW', { ...root, view: root });
},
create: create$3,
mixins: {
styles: ['translateX', 'translateY', 'opacity'],
animations: {
opacity: { type: 'tween', duration: 250 },
translateX: 'spring',
translateY: 'spring'
}
}
});
/**
* Button definitions for the file view
*/
const Buttons = {
AbortItemLoad: {
label: 'GET_LABEL_BUTTON_ABORT_ITEM_LOAD',
action: 'ABORT_ITEM_LOAD',
className: 'filepond--action-abort-item-load',
align: 'LOAD_INDICATOR_POSITION' // right
},
RetryItemLoad: {
label: 'GET_LABEL_BUTTON_RETRY_ITEM_LOAD',
action: 'RETRY_ITEM_LOAD',
icon: 'GET_ICON_RETRY',
className: 'filepond--action-retry-item-load',
align: 'BUTTON_PROCESS_ITEM_POSITION' // right
},
RemoveItem: {
label: 'GET_LABEL_BUTTON_REMOVE_ITEM',
action: 'REQUEST_REMOVE_ITEM',
icon: 'GET_ICON_REMOVE',
className: 'filepond--action-remove-item',
align: 'BUTTON_REMOVE_ITEM_POSITION' // left
},
ProcessItem: {
label: 'GET_LABEL_BUTTON_PROCESS_ITEM',
action: 'REQUEST_ITEM_PROCESSING',
icon: 'GET_ICON_PROCESS',
className: 'filepond--action-process-item',
align: 'BUTTON_PROCESS_ITEM_POSITION' // right
},
AbortItemProcessing: {
label: 'GET_LABEL_BUTTON_ABORT_ITEM_PROCESSING',
action: 'ABORT_ITEM_PROCESSING',
className: 'filepond--action-abort-item-processing',
align: 'BUTTON_PROCESS_ITEM_POSITION' // right
},
RetryItemProcessing: {
label: 'GET_LABEL_BUTTON_RETRY_ITEM_PROCESSING',
action: 'RETRY_ITEM_PROCESSING',
icon: 'GET_ICON_RETRY',
className: 'filepond--action-retry-item-processing',
align: 'BUTTON_PROCESS_ITEM_POSITION' // right
},
RevertItemProcessing: {
label: 'GET_LABEL_BUTTON_UNDO_ITEM_PROCESSING',
action: 'REQUEST_REVERT_ITEM_PROCESSING',
icon: 'GET_ICON_UNDO',
className: 'filepond--action-revert-item-processing',
align: 'BUTTON_PROCESS_ITEM_POSITION' // right
}
};
// make a list of buttons, we can then remove buttons from this list if they're disabled
const ButtonKeys = [];
forin(Buttons, key => {
ButtonKeys.push(key);
});
const calculateFileInfoOffset = root => {
const buttonRect = root.ref.buttonRemoveItem.rect.element;
return buttonRect.hidden ? null : buttonRect.width + buttonRect.left;
};
// Force on full pixels so text stays crips
const calculateFileVerticalCenterOffset = root =>
Math.floor(root.ref.buttonRemoveItem.rect.element.height / 4);
const calculateFileHorizontalCenterOffset = root =>
Math.floor(root.ref.buttonRemoveItem.rect.element.left / 2);
const getLoadIndicatorAlignment = root =>
root.query('GET_STYLE_LOAD_INDICATOR_POSITION');
const getProcessIndicatorAlignment = root =>
root.query('GET_STYLE_PROGRESS_INDICATOR_POSITION');
const getRemoveIndicatorAligment = root =>
root.query('GET_STYLE_BUTTON_REMOVE_ITEM_POSITION');
const DefaultStyle = {
buttonAbortItemLoad: { opacity: 0 },
buttonRetryItemLoad: { opacity: 0 },
buttonRemoveItem: { opacity: 0 },
buttonProcessItem: { opacity: 0 },
buttonAbortItemProcessing: { opacity: 0 },
buttonRetryItemProcessing: { opacity: 0 },
buttonRevertItemProcessing: { opacity: 0 },
loadProgressIndicator: { opacity: 0, align: getLoadIndicatorAlignment },
processProgressIndicator: { opacity: 0, align: getProcessIndicatorAlignment },
processingCompleteIndicator: { opacity: 0, scaleX: 0.75, scaleY: 0.75 },
info: { translateX: 0, translateY: 0, opacity: 0 },
status: { translateX: 0, translateY: 0, opacity: 0 }
};
const IdleStyle = {
buttonRemoveItem: { opacity: 1 },
buttonProcessItem: { opacity: 1 },
info: { translateX: calculateFileInfoOffset },
status: { translateX: calculateFileInfoOffset }
};
const ProcessingStyle = {
buttonAbortItemProcessing: { opacity: 1 },
processProgressIndicator: { opacity: 1 },
status: { opacity: 1 }
};
const StyleMap = {
DID_THROW_ITEM_INVALID: {
buttonRemoveItem: { opacity: 1 },
info: { translateX: calculateFileInfoOffset },
status: { translateX: calculateFileInfoOffset, opacity: 1 }
},
DID_START_ITEM_LOAD: {
buttonAbortItemLoad: { opacity: 1 },
loadProgressIndicator: { opacity: 1 },
status: { opacity: 1 }
},
DID_THROW_ITEM_LOAD_ERROR: {
buttonRetryItemLoad: { opacity: 1 },
buttonRemoveItem: { opacity: 1 },
info: { translateX: calculateFileInfoOffset },
status: { opacity: 1 }
},
DID_START_ITEM_REMOVE: {
processProgressIndicator: { opacity: 1, align: getRemoveIndicatorAligment },
info: { translateX: calculateFileInfoOffset },
status: { opacity: 0 }
},
DID_THROW_ITEM_REMOVE_ERROR: {
processProgressIndicator: { opacity: 0, align: getRemoveIndicatorAligment },
buttonRemoveItem: { opacity: 1 },
info: { translateX: calculateFileInfoOffset },
status: { opacity: 1, translateX: calculateFileInfoOffset }
},
DID_LOAD_ITEM: IdleStyle,
DID_LOAD_LOCAL_ITEM: {
buttonRemoveItem: { opacity: 1 },
info: { translateX: calculateFileInfoOffset },
status: { translateX: calculateFileInfoOffset }
},
DID_START_ITEM_PROCESSING: ProcessingStyle,
DID_REQUEST_ITEM_PROCESSING: ProcessingStyle,
DID_UPDATE_ITEM_PROCESS_PROGRESS: ProcessingStyle,
DID_COMPLETE_ITEM_PROCESSING: {
buttonRevertItemProcessing: { opacity: 1 },
info: { opacity: 1 },
status: { opacity: 1 }
},
DID_THROW_ITEM_PROCESSING_ERROR: {
buttonRemoveItem: { opacity: 1 },
buttonRetryItemProcessing: { opacity: 1 },
status: { opacity: 1 },
info: { translateX: calculateFileInfoOffset }
},
DID_THROW_ITEM_PROCESSING_REVERT_ERROR: {
buttonRevertItemProcessing: { opacity: 1 },
status: { opacity: 1 },
info: { opacity: 1 }
},
DID_ABORT_ITEM_PROCESSING: {
buttonRemoveItem: { opacity: 1 },
buttonProcessItem: { opacity: 1 },
info: { translateX: calculateFileInfoOffset },
status: { opacity: 1 }
},
DID_REVERT_ITEM_PROCESSING: IdleStyle
};
// complete indicator view
const processingCompleteIndicatorView = createView({
create: ({ root }) => {
root.element.innerHTML = root.query('GET_ICON_DONE');
},
name: 'processing-complete-indicator',
ignoreRect: true,
mixins: {
styles: ['scaleX', 'scaleY', 'opacity'],
animations: {
scaleX: 'spring',
scaleY: 'spring',
opacity: { type: 'tween', duration: 250 }
}
}
});
/**
* Creates the file view
*/
const create$4 = ({ root, props }) => {
const { id } = props;
// allow reverting upload
const allowRevert = root.query('GET_ALLOW_REVERT');
// is instant uploading, need this to determine the icon of the undo button
const instantUpload = root.query('GET_INSTANT_UPLOAD');
// is async set up
const isAsync = root.query('IS_ASYNC');
// enabled buttons array
const enabledButtons = isAsync
? ButtonKeys.concat()
: ButtonKeys.filter(key => !/Process/.test(key));
// remove last button (revert) if not allowed
if (isAsync && !allowRevert) {
enabledButtons.splice(-1, 1);
const map = StyleMap['DID_COMPLETE_ITEM_PROCESSING'];
map.info.translateX = calculateFileHorizontalCenterOffset;
map.info.translateY = calculateFileVerticalCenterOffset;
map.status.translateY = calculateFileVerticalCenterOffset;
map.processingCompleteIndicator = { opacity: 1, scaleX: 1, scaleY: 1 };
}
// update icon and label for revert button when instant uploading
if (instantUpload && allowRevert) {
Buttons['RevertItemProcessing'].label = 'GET_LABEL_BUTTON_REMOVE_ITEM';
Buttons['RevertItemProcessing'].icon = 'GET_ICON_REMOVE';
}
// create the button views
forin(Buttons, (key, definition) => {
// create button
const buttonView = root.createChildView(fileActionButton, {
label: root.query(definition.label),
icon: root.query(definition.icon),
opacity: 0
});
// should be appended?
if (enabledButtons.includes(key)) {
root.appendChildView(buttonView);
}
// add position attribute
buttonView.element.dataset.align = root.query(
`GET_STYLE_${definition.align}`
);
// add class
buttonView.element.classList.add(definition.className);
// handle interactions
buttonView.on('click', e => {
e.stopPropagation();
root.dispatch(definition.action, { query: id });
});
// set reference
root.ref[`button${key}`] = buttonView;
});
// create file info view
root.ref.info = root.appendChildView(root.createChildView(fileInfo, { id }));
// create file status view
root.ref.status = root.appendChildView(
root.createChildView(fileStatus, { id })
);
// checkmark
root.ref.processingCompleteIndicator = root.appendChildView(
root.createChildView(processingCompleteIndicatorView)
);
root.ref.processingCompleteIndicator.element.dataset.align = root.query(
`GET_STYLE_BUTTON_PROCESS_ITEM_POSITION`
);
// add progress indicators
const loadIndicatorView = root.appendChildView(
root.createChildView(progressIndicator, {
opacity: 0,
align: root.query(`GET_STYLE_LOAD_INDICATOR_POSITION`)
})
);
loadIndicatorView.element.classList.add('filepond--load-indicator');
root.ref.loadProgressIndicator = loadIndicatorView;
const progressIndicatorView = root.appendChildView(
root.createChildView(progressIndicator, {
opacity: 0,
align: root.query(`GET_STYLE_PROGRESS_INDICATOR_POSITION`)
})
);
progressIndicatorView.element.classList.add('filepond--process-indicator');
root.ref.processProgressIndicator = progressIndicatorView;
// current active styles
root.ref.activeStyles = [];
};
const write$2 = ({ root, actions, props }) => {
// route actions
route({ root, actions, props });
// select last state change action
let action = actions
.concat()
.filter(action => /^DID_/.test(action.type))
.reverse()
.find(action => StyleMap[action.type]);
// a new action happened, let's get the matching styles
if (action) {
// define new active styles
root.ref.activeStyles = [];
const stylesToApply = StyleMap[action.type];
forin(DefaultStyle, (name, defaultStyles) => {
// get reference to control
const control = root.ref[name];
// loop over all styles for this control
forin(defaultStyles, (key, defaultValue) => {
const value =
stylesToApply[name] && typeof stylesToApply[name][key] !== 'undefined'
? stylesToApply[name][key]
: defaultValue;
root.ref.activeStyles.push({ control, key, value });
});
});
}
// apply active styles to element
root.ref.activeStyles.forEach(({ control, key, value }) => {
control[key] = typeof value === 'function' ? value(root) : value;
});
};
const route = createRoute({
DID_SET_LABEL_BUTTON_ABORT_ITEM_PROCESSING: ({ root, action }) => {
root.ref.buttonAbortItemProcessing.label = action.value;
},
DID_SET_LABEL_BUTTON_ABORT_ITEM_LOAD: ({ root, action }) => {
root.ref.buttonAbortItemLoad.label = action.value;
},
DID_SET_LABEL_BUTTON_ABORT_ITEM_REMOVAL: ({ root, action }) => {
root.ref.buttonAbortItemRemoval.label = action.value;
},
DID_REQUEST_ITEM_PROCESSING: ({ root }) => {
root.ref.processProgressIndicator.spin = true;
root.ref.processProgressIndicator.progress = 0;
},
DID_START_ITEM_LOAD: ({ root }) => {
root.ref.loadProgressIndicator.spin = true;
root.ref.loadProgressIndicator.progress = 0;
},
DID_START_ITEM_REMOVE: ({ root }) => {
root.ref.processProgressIndicator.spin = true;
root.ref.processProgressIndicator.progress = 0;
},
DID_UPDATE_ITEM_LOAD_PROGRESS: ({ root, action }) => {
root.ref.loadProgressIndicator.spin = false;
root.ref.loadProgressIndicator.progress = action.progress;
},
DID_UPDATE_ITEM_PROCESS_PROGRESS: ({ root, action }) => {
root.ref.processProgressIndicator.spin = false;
root.ref.processProgressIndicator.progress = action.progress;
}
});
const file = createView({
create: create$4,
write: write$2,
didCreateView: root => {
applyFilters('CREATE_VIEW', { ...root, view: root });
},
name: 'file'
});
/**
* Creates the file view
*/
const create$5 = ({ root, props }) => {
// filename
root.ref.fileName = createElement$1('legend');
root.appendChild(root.ref.fileName);
// file appended
root.ref.file = root.appendChildView(
root.createChildView(file, { id: props.id })
);
// create data container
const dataContainer = createElement$1('input');
dataContainer.type = 'hidden';
dataContainer.name = root.query('GET_NAME');
dataContainer.disabled = root.query('GET_DISABLED');
root.ref.data = dataContainer;
root.appendChild(dataContainer);
};
const didSetDisabled = ({ root }) => {
root.ref.data.disabled = root.query('GET_DISABLED');
};
/**
* Data storage
*/
const didLoadItem = ({ root, action, props }) => {
root.ref.data.value = action.serverFileReference;
// updates the legend of the fieldset so screenreaders can better group buttons
text(
root.ref.fileName,
formatFilename(root.query('GET_ITEM_NAME', props.id))
);
};
const didRemoveItem = ({ root }) => {
root.ref.data.removeAttribute('value');
};
const didCompleteItemProcessing$1 = ({ root, action }) => {
root.ref.data.value = action.serverFileReference;
};
const didRevertItemProcessing = ({ root }) => {
root.ref.data.removeAttribute('value');
};
const fileWrapper = createView({
create: create$5,
ignoreRect: true,
write: createRoute({
DID_SET_DISABLED: didSetDisabled,
DID_LOAD_ITEM: didLoadItem,
DID_REMOVE_ITEM: didRemoveItem,
DID_COMPLETE_ITEM_PROCESSING: didCompleteItemProcessing$1,
DID_REVERT_ITEM_PROCESSING: didRevertItemProcessing
}),
didCreateView: root => {
applyFilters('CREATE_VIEW', { ...root, view: root });
},
tag: 'fieldset',
name: 'file-wrapper'
});
const PANEL_SPRING_PROPS = { type: 'spring', damping: 0.6, mass: 7 };
const create$6 = ({ root, props }) => {
[
{
name: 'top'
},
{
name: 'center',
props: {
translateY: null,
scaleY: null
},
mixins: {
animations: {
scaleY: PANEL_SPRING_PROPS
},
styles: ['translateY', 'scaleY']
}
},
{
name: 'bottom',
props: {
translateY: null
},
mixins: {
animations: {
translateY: PANEL_SPRING_PROPS
},
styles: ['translateY']
}
}
].forEach(section => {
createSection(root, section, props.name);
});
root.element.classList.add(`filepond--${props.name}`);
root.ref.scalable = null;
};
const createSection = (root, section, className) => {
const viewConstructor = createView({
name: `panel-${section.name} filepond--${className}`,
mixins: section.mixins,
ignoreRectUpdate: true
});
const view = root.createChildView(viewConstructor, section.props);
root.ref[section.name] = root.appendChildView(view);
};
const write$3 = ({ root, props }) => {
// update scalable state
if (root.ref.scalable === null || props.scalable !== root.ref.scalable) {
root.ref.scalable = isBoolean(props.scalable) ? props.scalable : true;
root.element.dataset.scalable = root.ref.scalable;
}
// no height, can't set
if (!props.height) return;
// get child rects
const topRect = root.ref.top.rect.element;
const bottomRect = root.ref.bottom.rect.element;
// make sure height never is smaller than bottom and top seciton heights combined (will probably never happen, but who knows)
const height = Math.max(topRect.height + bottomRect.height, props.height);
// offset center part
root.ref.center.translateY = topRect.height;
// scale center part
// use math ceil to prevent transparent lines because of rounding errors
root.ref.center.scaleY = (height - topRect.height - bottomRect.height) / 100;
// offset bottom part
root.ref.bottom.translateY = height - bottomRect.height;
};
const panel = createView({
name: 'panel',
write: write$3,
create: create$6,
ignoreRect: true,
mixins: {
apis: ['height', 'scalable']
}
});
const ITEM_TRANSLATE_SPRING = {
type: 'spring',
stiffness: 0.75,
damping: 0.45,
mass: 10
};
const ITEM_SCALE_SPRING = 'spring';
/**
* Creates the file view
*/
const create$7 = ({ root, props }) => {
// select
root.ref.handleClick = () =>
root.dispatch('DID_ACTIVATE_ITEM', { id: props.id });
// set id
root.element.id = `filepond--item-${props.id}`;
root.element.addEventListener('click', root.ref.handleClick);
// file view
root.ref.container = root.appendChildView(
root.createChildView(fileWrapper, { id: props.id })
);
// file panel
root.ref.panel = root.appendChildView(
root.createChildView(panel, { name: 'item-panel' })
);
// default start height
root.ref.panel.height = null;
// by default not marked for removal
props.markedForRemoval = false;
};
const StateMap = {
DID_START_ITEM_LOAD: 'busy',
DID_UPDATE_ITEM_LOAD_PROGRESS: 'loading',
DID_THROW_ITEM_INVALID: 'load-invalid',
DID_THROW_ITEM_LOAD_ERROR: 'load-error',
DID_LOAD_ITEM: 'idle',
DID_THROW_ITEM_REMOVE_ERROR: 'remove-error',
DID_START_ITEM_REMOVE: 'busy',
DID_START_ITEM_PROCESSING: 'busy',
DID_REQUEST_ITEM_PROCESSING: 'busy',
DID_UPDATE_ITEM_PROCESS_PROGRESS: 'processing',
DID_COMPLETE_ITEM_PROCESSING: 'processing-complete',
DID_THROW_ITEM_PROCESSING_ERROR: 'processing-error',
DID_THROW_ITEM_PROCESSING_REVERT_ERROR: 'processing-revert-error',
DID_ABORT_ITEM_PROCESSING: 'cancelled',
DID_REVERT_ITEM_PROCESSING: 'idle'
};
const route$1 = createRoute({
DID_UPDATE_PANEL_HEIGHT: ({ root, action }) => {
const { height } = action;
root.height = height;
}
});
const write$4 = ({ root, actions, props, shouldOptimize }) => {
// select last state change action
let action = actions
.concat()
.filter(action => /^DID_/.test(action.type))
.reverse()
.find(action => StateMap[action.type]);
// no need to set same state twice
if (action && action.type !== props.currentState) {
// set current state
props.currentState = action.type;
// set state
root.element.dataset.filepondItemState = StateMap[props.currentState] || '';
}
// route actions
const aspectRatio =
root.query('GET_ITEM_PANEL_ASPECT_RATIO') ||
root.query('GET_PANEL_ASPECT_RATIO');
if (!aspectRatio) {
route$1({ root, actions, props });
if (!root.height && root.ref.container.rect.element.height > 0) {
root.height = root.ref.container.rect.element.height;
}
} else if (!shouldOptimize) {
root.height = root.rect.element.width * aspectRatio;
}
// sync panel height with item height
if (shouldOptimize) {
root.ref.panel.height = null;
}
root.ref.panel.height = root.height;
};
const item = createView({
create: create$7,
write: write$4,
destroy: ({ root, props }) => {
root.element.removeEventListener('click', root.ref.handleClick);
root.dispatch('RELEASE_ITEM', { query: props.id });
},
tag: 'li',
name: 'item',
mixins: {
apis: ['id', 'interactionMethod', 'markedForRemoval', 'spawnDate'],
styles: [
'translateX',
'translateY',
'scaleX',
'scaleY',
'opacity',
'height'
],
animations: {
scaleX: ITEM_SCALE_SPRING,
scaleY: ITEM_SCALE_SPRING,
translateX: ITEM_TRANSLATE_SPRING,
translateY: ITEM_TRANSLATE_SPRING,
opacity: { type: 'tween', duration: 150 }
}
}
});
const getItemIndexByPosition = (view, positionInView) => {
if (!positionInView) return;
const horizontalSpace = view.rect.element.width;
const children = view.childViews;
const l = children.length;
let last = null;
// -1, don't move items to accomodate (either add to top or bottom)
if (l === 0 || positionInView.top < children[0].rect.element.top) return -1;
// let's get the item width
const item = children[0];
const itemRect = item.rect.element;
const itemHorizontalMargin = itemRect.marginLeft + itemRect.marginRight;
const itemWidth = itemRect.width + itemHorizontalMargin;
const itemsPerRow = Math.round(horizontalSpace / itemWidth);
// stack
if (itemsPerRow === 1) {
for (let index = 0; index < l; index++) {
const child = children[index];
const childMid = child.rect.outer.top + child.rect.element.height * 0.5;
if (positionInView.top < childMid) {
return index;
}
}
return l;
}
// grid
const itemVerticalMargin = itemRect.marginTop + itemRect.marginBottom;
const itemHeight = itemRect.height + itemVerticalMargin;
for (let index = 0; index < l; index++) {
const indexX = index % itemsPerRow;
const indexY = Math.floor(index / itemsPerRow);
const offsetX = indexX * itemWidth;
const offsetY = indexY * itemHeight;
const itemTop = offsetY - itemRect.marginTop;
const itemRight = offsetX + itemWidth;
const itemBottom = offsetY + itemHeight + itemRect.marginBottom;
if (positionInView.top < itemBottom && positionInView.top > itemTop) {
if (positionInView.left < itemRight) {
return index;
} else if (index !== l - 1) {
last = index;
} else {
last = null;
}
}
}
if (last !== null) {
return last;
}
return l;
};
const create$8 = ({ root }) => {
// need to set role to list as otherwise it won't be read as a list by VoiceOver
attr(root.element, 'role', 'list');
root.ref.lastItemSpanwDate = Date.now();
};
/**
* Inserts a new item
* @param root
* @param action
*/
const addItemView = ({ root, action }) => {
const { id, index, interactionMethod } = action;
root.ref.addIndex = index;
const now = Date.now();
let spawnDate = now;
let opacity = 1;
if (interactionMethod !== InteractionMethod.NONE) {
opacity = 0;
const cooldown = root.query('GET_ITEM_INSERT_INTERVAL');
const dist = now - root.ref.lastItemSpanwDate;
spawnDate = dist < cooldown ? now + (cooldown - dist) : now;
}
root.ref.lastItemSpanwDate = spawnDate;
root.appendChildView(
root.createChildView(
// view type
item,
// props
{
spawnDate,
id,
opacity,
interactionMethod
}
),
index
);
};
const moveItem = (item, x, y, vx = 0, vy = 1) => {
item.translateX = x;
item.translateY = y;
if (Date.now() > item.spawnDate) {
// reveal element
if (item.opacity === 0) {
introItemView(item, x, y, vx, vy);
}
// make sure is default scale every frame
item.scaleX = 1;
item.scaleY = 1;
item.opacity = 1;
}
};
const introItemView = (item, x, y, vx, vy) => {
if (item.interactionMethod === InteractionMethod.NONE) {
item.translateX = null;
item.translateX = x;
item.translateY = null;
item.translateY = y;
} else if (item.interactionMethod === InteractionMethod.DROP) {
item.translateX = null;
item.translateX = x - vx * 20;
item.translateY = null;
item.translateY = y - vy * 10;
item.scaleX = 0.8;
item.scaleY = 0.8;
} else if (item.interactionMethod === InteractionMethod.BROWSE) {
item.translateY = null;
item.translateY = y - 30;
} else if (item.interactionMethod === InteractionMethod.API) {
item.translateX = null;
item.translateX = x - 30;
item.translateY = null;
}
};
/**
* Removes an existing item
* @param root
* @param action
*/
const removeItemView = ({ root, action }) => {
const { id } = action;
// get the view matching the given id
const view = root.childViews.find(child => child.id === id);
// if no view found, exit
if (!view) {
return;
}
// animate view out of view
view.scaleX = 0.9;
view.scaleY = 0.9;
view.opacity = 0;
// mark for removal
view.markedForRemoval = true;
};
/**
* Setup action routes
*/
const route$2 = createRoute({
DID_ADD_ITEM: addItemView,
DID_REMOVE_ITEM: removeItemView
});
/**
* Write to view
* @param root
* @param actions
* @param props
*/
const write$5 = ({ root, props, actions, shouldOptimize }) => {
// route actions
route$2({ root, props, actions });
const { dragCoordinates } = props;
// get index
const dragIndex = dragCoordinates
? getItemIndexByPosition(root, dragCoordinates)
: null;
// available space on horizontal axis
const horizontalSpace = root.rect.element.width;
// only draw children that have dimensions
const visibleChildren = root.childViews.filter(
child => child.rect.element.height
);
// sort based on current active items
const children = root
.query('GET_ACTIVE_ITEMS')
.map(item => visibleChildren.find(child => child.id === item.id))
.filter(item => item);
// add index is used to reserve the dropped/added item index till the actual item is rendered
const addIndex = root.ref.addIndex || null;
// add index no longer needed till possibly next draw
root.ref.addIndex = null;
let dragIndexOffset = 0;
let removeIndexOffset = 0;
let addIndexOffset = 0;
if (children.length === 0) return;
const childRect = children[0].rect.element;
const itemVerticalMargin = childRect.marginTop + childRect.marginBottom;
const itemHorizontalMargin = childRect.marginLeft + childRect.marginRight;
const itemWidth = childRect.width + itemHorizontalMargin;
const itemHeight = childRect.height + itemVerticalMargin;
const itemsPerRow = Math.round(horizontalSpace / itemWidth);
// stack
if (itemsPerRow === 1) {
let offsetY = 0;
let dragOffset = 0;
children.forEach((child, index) => {
if (dragIndex) {
let dist = index - dragIndex;
if (dist === -2) {
dragOffset = -itemVerticalMargin * 0.25;
} else if (dist === -1) {
dragOffset = -itemVerticalMargin * 0.75;
} else if (dist === 0) {
dragOffset = itemVerticalMargin * 0.75;
} else if (dist === 1) {
dragOffset = itemVerticalMargin * 0.25;
} else {
dragOffset = 0;
}
}
if (shouldOptimize) {
child.translateX = null;
child.translateY = null;
}
if (!child.markedForRemoval) {
moveItem(child, 0, offsetY + dragOffset);
}
let itemHeight = child.rect.element.height + itemVerticalMargin;
let visualHeight =
itemHeight * (child.markedForRemoval ? child.opacity : 1);
offsetY += visualHeight;
});
}
// grid
else {
let prevX = 0;
let prevY = 0;
children.forEach((child, index) => {
if (index === dragIndex) {
dragIndexOffset = 1;
}
if (index === addIndex) {
addIndexOffset += 1;
}
if (child.markedForRemoval && child.opacity < 0.5) {
removeIndexOffset -= 1;
}
const visualIndex =
index + addIndexOffset + dragIndexOffset + removeIndexOffset;
const indexX = visualIndex % itemsPerRow;
const indexY = Math.floor(visualIndex / itemsPerRow);
const offsetX = indexX * itemWidth;
const offsetY = indexY * itemHeight;
const vectorX = Math.sign(offsetX - prevX);
const vectorY = Math.sign(offsetY - prevY);
prevX = offsetX;
prevY = offsetY;
if (child.markedForRemoval) return;
if (shouldOptimize) {
child.translateX = null;
child.translateY = null;
}
moveItem(child, offsetX, offsetY, vectorX, vectorY);
});
}
};
/**
* Filters actions that are meant specifically for a certain child of the list
* @param child
* @param actions
*/
const filterSetItemActions = (child, actions) =>
actions.filter(action => {
// if action has an id, filter out actions that don't have this child id
if (action.data && action.data.id) {
return child.id === action.data.id;
}
// allow all other actions
return true;
});
const list = createView({
create: create$8,
write: write$5,
tag: 'ul',
name: 'list',
didWriteView: ({ root }) => {
root.childViews
.filter(
view => view.markedForRemoval && view.opacity === 0 && view.resting
)
.forEach(view => {
view._destroy();
root.removeChildView(view);
});
},
filterFrameActionsForChild: filterSetItemActions,
mixins: {
apis: ['dragCoordinates']
}
});
const create$9 = ({ root, props }) => {
root.ref.list = root.appendChildView(root.createChildView(list));
props.dragCoordinates = null;
props.overflowing = false;
};
const storeDragCoordinates = ({ root, props, action }) => {
if (!root.query('GET_ITEM_INSERT_LOCATION_FREEDOM')) return;
props.dragCoordinates = {
left: action.position.scopeLeft - root.ref.list.rect.element.left,
top:
action.position.scopeTop -
(root.rect.outer.top +
root.rect.element.marginTop +
root.rect.element.scrollTop)
};
};
const clearDragCoordinates = ({ props }) => {
props.dragCoordinates = null;
};
const route$3 = createRoute({
DID_DRAG: storeDragCoordinates,
DID_END_DRAG: clearDragCoordinates
});
const write$6 = ({ root, props, actions }) => {
// route actions
route$3({ root, props, actions });
// current drag position
root.ref.list.dragCoordinates = props.dragCoordinates;
// if currently overflowing but no longer received overflow
if (props.overflowing && !props.overflow) {
props.overflowing = false;
// reset overflow state
root.element.dataset.state = '';
root.height = null;
}
// if is not overflowing currently but does receive overflow value
if (props.overflow) {
const newHeight = Math.round(props.overflow);
if (newHeight !== root.height) {
props.overflowing = true;
root.element.dataset.state = 'overflow';
root.height = newHeight;
}
}
};
const listScroller = createView({
create: create$9,
write: write$6,
name: 'list-scroller',
mixins: {
apis: ['overflow', 'dragCoordinates'],
styles: ['height', 'translateY'],
animations: {
translateY: 'spring'
}
}
});
const attrToggle = (element, name, state, enabledValue = '') => {
if (state) {
attr(element, name, enabledValue);
} else {
element.removeAttribute(name);
}
};
const resetFileInput = input => {
// no value, no need to reset
if (!input || input.value === '') {
return;
}
try {
// for modern browsers
input.value = '';
} catch (err) {}
// for IE10
if (input.value) {
// quickly append input to temp form and reset form
const form = createElement$1('form');
const parentNode = input.parentNode;
const ref = input.nextSibling;
form.appendChild(input);
form.reset();
// re-inject input where it originally was
if (ref) {
parentNode.insertBefore(input, ref);
} else {
parentNode.appendChild(input);
}
}
};
const create$a = ({ root, props }) => {
// set id so can be referenced from outside labels
root.element.id = `filepond--browser-${props.id}`;
// set name of element (is removed when a value is set)
attr(root.element, 'name', root.query('GET_NAME'));
// we have to link this element to the status element
attr(root.element, 'aria-controls', `filepond--assistant-${props.id}`);
// set label, we use labelled by as otherwise the screenreader does not read the "browse" text in the label (as it has tabindex: 0)
attr(root.element, 'aria-labelledby', `filepond--drop-label-${props.id}`);
// handle changes to the input field
root.ref.handleChange = e => {
if (!root.element.value) {
return;
}
// extract files
const files = Array.from(root.element.files);
// we add a little delay so the OS file select window can move out of the way before we add our file
setTimeout(() => {
// load files
props.onload(files);
// reset input, it's just for exposing a method to drop files, should not retain any state
resetFileInput(root.element);
}, 250);
};
root.element.addEventListener('change', root.ref.handleChange);
};
const setAcceptedFileTypes = ({ root, action }) => {
attrToggle(
root.element,
'accept',
!!action.value,
action.value ? action.value.join(',') : ''
);
};
const toggleAllowMultiple = ({ root, action }) => {
attrToggle(root.element, 'multiple', action.value);
};
const toggleDisabled = ({ root, action }) => {
const isDisabled = root.query('GET_DISABLED');
const doesAllowBrowse = root.query('GET_ALLOW_BROWSE');
const disableField = isDisabled || !doesAllowBrowse;
attrToggle(root.element, 'disabled', disableField);
};
const toggleRequired = ({ root, action }) => {
// want to remove required, always possible
if (!action.value) {
attrToggle(root.element, 'required', false);
}
// if want to make required, only possible when zero items
else if (root.query('GET_TOTAL_ITEMS') === 0) {
attrToggle(root.element, 'required', true);
}
};
const setCaptureMethod = ({ root, action }) => {
attrToggle(
root.element,
'capture',
!!action.value,
action.value === true ? '' : action.value
);
};
const updateRequiredStatus = ({ root }) => {
const { element } = root;
// always remove the required attribute when more than zero items
if (root.query('GET_TOTAL_ITEMS') > 0) {
attrToggle(element, 'required', false);
attrToggle(element, 'name', false);
} else {
// add name attribute
attrToggle(element, 'name', true, root.query('GET_NAME'));
// remove any validation messages
const shouldCheckValidity = root.query('GET_CHECK_VALIDITY');
if (shouldCheckValidity) {
element.setCustomValidity('');
}
// we only add required if the field has been deemed required
if (root.query('GET_REQUIRED')) {
attrToggle(element, 'required', true);
}
}
};
const updateFieldValidityStatus = ({ root }) => {
const shouldCheckValidity = root.query('GET_CHECK_VALIDITY');
if (!shouldCheckValidity) return;
root.element.setCustomValidity(root.query('GET_LABEL_INVALID_FIELD'));
};
const browser = createView({
tag: 'input',
name: 'browser',
ignoreRect: true,
ignoreRectUpdate: true,
attributes: {
type: 'file'
},
create: create$a,
destroy: ({ root }) => {
root.element.removeEventListener('change', root.ref.handleChange);
},
write: createRoute({
DID_LOAD_ITEM: updateRequiredStatus,
DID_REMOVE_ITEM: updateRequiredStatus,
DID_THROW_ITEM_INVALID: updateFieldValidityStatus,
DID_SET_DISABLED: toggleDisabled,
DID_SET_ALLOW_BROWSE: toggleDisabled,
DID_SET_ALLOW_MULTIPLE: toggleAllowMultiple,
DID_SET_ACCEPTED_FILE_TYPES: setAcceptedFileTypes,
DID_SET_CAPTURE_METHOD: setCaptureMethod,
DID_SET_REQUIRED: toggleRequired
})
});
const Key = {
ENTER: 13,
SPACE: 32
};
const create$b = ({ root, props }) => {
// create the label and link it to the file browser
const label = createElement$1('label');
attr(label, 'for', `filepond--browser-${props.id}`);
// use for labeling file input (aria-labelledby on file input)
attr(label, 'id', `filepond--drop-label-${props.id}`);
// hide the label for screenreaders, the input element will read the contents of the label when it's focussed. If we don't set aria-hidden the screenreader will also navigate the contents of the label separately from the input.
attr(label, 'aria-hidden', 'true');
// handle keys
root.ref.handleKeyDown = e => {
const isActivationKey = e.keyCode === Key.ENTER || e.keyCode === Key.SPACE;
if (!isActivationKey) return;
// stops from triggering the element a second time
e.preventDefault();
// click link (will then in turn activate file input)
root.ref.label.click();
};
root.ref.handleClick = e => {
const isLabelClick = e.target === label || label.contains(e.target);
// don't want to click twice
if (isLabelClick) return;
// click link (will then in turn activate file input)
root.ref.label.click();
};
// attach events
label.addEventListener('keydown', root.ref.handleKeyDown);
root.element.addEventListener('click', root.ref.handleClick);
// update
updateLabelValue(label, props.caption);
// add!
root.appendChild(label);
root.ref.label = label;
};
const updateLabelValue = (label, value) => {
label.innerHTML = value;
const clickable = label.querySelector('.filepond--label-action');
if (clickable) {
attr(clickable, 'tabindex', '0');
}
return value;
};
const dropLabel = createView({
name: 'drop-label',
ignoreRect: true,
create: create$b,
destroy: ({ root }) => {
root.ref.label.addEventListener('keydown', root.ref.handleKeyDown);
root.element.removeEventListener('click', root.ref.handleClick);
},
write: createRoute({
DID_SET_LABEL_IDLE: ({ root, action }) => {
updateLabelValue(root.ref.label, action.value);
}
}),
mixins: {
styles: ['opacity', 'translateX', 'translateY'],
animations: {
opacity: { type: 'tween', duration: 150 },
translateX: 'spring',
translateY: 'spring'
}
}
});
const blob = createView({
name: 'drip-blob',
ignoreRect: true,
mixins: {
styles: ['translateX', 'translateY', 'scaleX', 'scaleY', 'opacity'],
animations: {
scaleX: 'spring',
scaleY: 'spring',
translateX: 'spring',
translateY: 'spring',
opacity: { type: 'tween', duration: 250 }
}
}
});
const addBlob = ({ root }) => {
const centerX = root.rect.element.width * 0.5;
const centerY = root.rect.element.height * 0.5;
root.ref.blob = root.appendChildView(
root.createChildView(blob, {
opacity: 0,
scaleX: 2.5,
scaleY: 2.5,
translateX: centerX,
translateY: centerY
})
);
};
const moveBlob = ({ root, action }) => {
if (!root.ref.blob) {
addBlob({ root });
return;
}
root.ref.blob.translateX = action.position.scopeLeft;
root.ref.blob.translateY = action.position.scopeTop;
root.ref.blob.scaleX = 1;
root.ref.blob.scaleY = 1;
root.ref.blob.opacity = 1;
};
const hideBlob = ({ root }) => {
if (!root.ref.blob) {
return;
}
root.ref.blob.opacity = 0;
};
const explodeBlob = ({ root }) => {
if (!root.ref.blob) {
return;
}
root.ref.blob.scaleX = 2.5;
root.ref.blob.scaleY = 2.5;
root.ref.blob.opacity = 0;
};
const write$7 = ({ root, props, actions }) => {
route$4({ root, props, actions });
const { blob } = root.ref;
if (actions.length === 0 && blob && blob.opacity === 0) {
root.removeChildView(blob);
root.ref.blob = null;
}
};
const route$4 = createRoute({
DID_DRAG: moveBlob,
DID_DROP: explodeBlob,
DID_END_DRAG: hideBlob
});
const drip = createView({
ignoreRect: true,
ignoreRectUpdate: true,
name: 'drip',
write: write$7
});
const getRootNode = element =>
'getRootNode' in element ? element.getRootNode() : document;
const images = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'tiff'];
const text$1 = ['css', 'csv', 'html', 'txt'];
const apps = ['rtf', 'pdf', 'json'];
const map = {
zip: 'zip|compressed',
epub: 'application/epub+zip'
};
const guesstimateMimeType = (extension = '') => {
extension = extension.toLowerCase();
if (images.includes(extension)) {
return (
'image/' +
(extension === 'jpg'
? 'jpeg'
: extension === 'svg'
? 'svg+xml'
: extension)
);
}
if (text$1.includes(extension)) {
return 'text/' + extension;
}
return map[extension] || null;
};
const requestDataTransferItems = dataTransfer =>
new Promise((resolve, reject) => {
// try to get links from transfer, if found we'll exit immidiately (unless a file is in the dataTransfer as well, this is because Firefox could represent the file as a URL and a file object at the same time)
const links = getLinks(dataTransfer);
if (links.length && !hasFiles(dataTransfer)) {
return resolve(links);
}
// try to get files from the transfer
getFiles(dataTransfer).then(resolve);
});
/**
* Test if datatransfer has files
*/
const hasFiles = dataTransfer => {
if (dataTransfer.files) return dataTransfer.files.length > 0;
return false;
};
/**
* Extracts files from a DataTransfer object
*/
const getFiles = dataTransfer =>
new Promise((resolve, reject) => {
// get the transfer items as promises
const promisedFiles = (dataTransfer.items
? Array.from(dataTransfer.items)
: []
)
// only keep file system items (files and directories)
.filter(item => isFileSystemItem(item))
// map each item to promise
.map(item => getFilesFromItem(item));
// if is empty, see if we can extract some info from the files property as a fallback
if (!promisedFiles.length) {
// TODO: test for directories (should not be allowed)
// Use FileReader, problem is that the files property gets lost in the process
resolve(dataTransfer.files ? Array.from(dataTransfer.files) : []);
return;
}
// done!
Promise.all(promisedFiles).then(returendFileGroups => {
// flatten groups
const files = [];
returendFileGroups.forEach(group => {
files.push.apply(files, group);
});
// done (filter out empty files)!
resolve(files.filter(file => file));
});
});
const isFileSystemItem = item => {
if (isEntry(item)) {
const entry = getAsEntry(item);
if (entry) {
return entry.isFile || entry.isDirectory;
}
}
return item.kind === 'file';
};
const getFilesFromItem = item =>
new Promise((resolve, reject) => {
if (isDirectoryEntry(item)) {
getFilesInDirectory(getAsEntry(item)).then(resolve);
return;
}
resolve([item.getAsFile()]);
});
const getFilesInDirectory = entry =>
new Promise((resolve, reject) => {
const files = [];
// the total entries to read
let totalFilesFound = 0;
// the recursive function
const readEntries = dirEntry => {
const directoryReader = dirEntry.createReader();
directoryReader.readEntries(entries => {
entries.forEach(entry => {
// recursively read more directories
if (entry.isDirectory) {
readEntries(entry);
} else {
// read as file
totalFilesFound++;
entry.file(file => {
files.push(correctMissingFileType(file));
if (totalFilesFound === files.length) {
resolve(files);
}
});
}
});
});
};
// go!
readEntries(entry);
});
const correctMissingFileType = file => {
if (file.type.length) return file;
const date = file.lastModifiedDate;
const name = file.name;
file = file.slice(
0,
file.size,
guesstimateMimeType(getExtensionFromFilename(file.name))
);
file.name = name;
file.lastModifiedDate = date;
return file;
};
const isDirectoryEntry = item =>
isEntry(item) && (getAsEntry(item) || {}).isDirectory;
const isEntry = item => 'webkitGetAsEntry' in item;
const getAsEntry = item => item.webkitGetAsEntry();
/**
* Extracts links from a DataTransfer object
*/
const getLinks = dataTransfer => {
let links = [];
try {
// look in meta data property
links = getLinksFromTransferMetaData(dataTransfer);
if (links.length) {
return links;
}
links = getLinksFromTransferURLData(dataTransfer);
} catch (e) {
// nope nope nope (probably IE trouble)
}
return links;
};
const getLinksFromTransferURLData = dataTransfer => {
let data = dataTransfer.getData('url');
if (typeof data === 'string' && data.length) {
return [data];
}
return [];
};
const getLinksFromTransferMetaData = dataTransfer => {
let data = dataTransfer.getData('text/html');
if (typeof data === 'string' && data.length) {
const matches = data.match(/src\s*=\s*"(.+?)"/);
if (matches) {
return [matches[1]];
}
}
return [];
};
const dragNDropObservers = [];
const eventPosition = e => ({
pageLeft: e.pageX,
pageTop: e.pageY,
scopeLeft: e.offsetX || e.layerX,
scopeTop: e.offsetY || e.layerY
});
const createDragNDropClient = (element, scopeToObserve, filterElement) => {
const observer = getDragNDropObserver(scopeToObserve);
const client = {
element,
filterElement,
state: null,
ondrop: () => {},
onenter: () => {},
ondrag: () => {},
onexit: () => {},
onload: () => {},
allowdrop: () => {}
};
client.destroy = observer.addListener(client);
return client;
};
const getDragNDropObserver = element => {
// see if already exists, if so, return
const observer = dragNDropObservers.find(item => item.element === element);
if (observer) {
return observer;
}
// create new observer, does not yet exist for this element
const newObserver = createDragNDropObserver(element);
dragNDropObservers.push(newObserver);
return newObserver;
};
const createDragNDropObserver = element => {
const clients = [];
const routes = {
dragenter,
dragover,
dragleave,
drop
};
const handlers = {};
forin(routes, (event, createHandler) => {
handlers[event] = createHandler(element, clients);
element.addEventListener(event, handlers[event], false);
});
const observer = {
element,
addListener: client => {
// add as client
clients.push(client);
// return removeListener function
return () => {
// remove client
clients.splice(clients.indexOf(client), 1);
// if no more clients, clean up observer
if (clients.length === 0) {
dragNDropObservers.splice(dragNDropObservers.indexOf(observer), 1);
forin(routes, event => {
element.removeEventListener(event, handlers[event], false);
});
}
};
}
};
return observer;
};
const elementFromPoint = (root, point) => {
if (!('elementFromPoint' in root)) {
root = document;
}
return root.elementFromPoint(point.x, point.y);
};
const isEventTarget = (e, target) => {
// get root
const root = getRootNode(target);
// get element at position
// if root is not actual shadow DOM and does not have elementFromPoint method, use the one on document
const elementAtPosition = elementFromPoint(root, {
x: e.pageX - window.pageXOffset,
y: e.pageY - window.pageYOffset
});
// test if target is the element or if one of its children is
return elementAtPosition === target || target.contains(elementAtPosition);
};
let initialTarget = null;
const setDropEffect = (dataTransfer, effect) => {
// is in try catch as IE11 will throw error if not
try {
dataTransfer.dropEffect = effect;
} catch (e) {}
};
const dragenter = (root, clients) => e => {
e.preventDefault();
initialTarget = e.target;
clients.forEach(client => {
const { element, onenter } = client;
if (isEventTarget(e, element)) {
client.state = 'enter';
// fire enter event
onenter(eventPosition(e));
}
});
};
const dragover = (root, clients) => e => {
e.preventDefault();
const dataTransfer = e.dataTransfer;
requestDataTransferItems(dataTransfer).then(items => {
let overDropTarget = false;
clients.some(client => {
const {
filterElement,
element,
onenter,
onexit,
ondrag,
allowdrop
} = client;
// by default we can drop
setDropEffect(dataTransfer, 'copy');
// allow transfer of these items
const allowsTransfer = allowdrop(items);
// only used when can be dropped on page
if (!allowsTransfer) {
setDropEffect(dataTransfer, 'none');
return;
}
// targetting this client
if (isEventTarget(e, element)) {
overDropTarget = true;
// had no previous state, means we are entering this client
if (client.state === null) {
client.state = 'enter';
onenter(eventPosition(e));
return;
}
// now over element (no matter if it allows the drop or not)
client.state = 'over';
// needs to allow transfer
if (filterElement && !allowsTransfer) {
setDropEffect(dataTransfer, 'none');
return;
}
// dragging
ondrag(eventPosition(e));
} else {
// should be over an element to drop
if (filterElement && !overDropTarget) {
setDropEffect(dataTransfer, 'none');
}
// might have just left this client?
if (client.state) {
client.state = null;
onexit(eventPosition(e));
}
}
});
});
};
const drop = (root, clients) => e => {
e.preventDefault();
const dataTransfer = e.dataTransfer;
requestDataTransferItems(dataTransfer).then(items => {
clients.forEach(client => {
const { filterElement, element, ondrop, onexit, allowdrop } = client;
client.state = null;
const allowsTransfer = allowdrop(items);
// no transfer for this client
if (!allowsTransfer) {
onexit(eventPosition(e));
return;
}
// if we're filtering on element we need to be over the element to drop
if (filterElement && !isEventTarget(e, element)) {
return;
}
ondrop(eventPosition(e), items);
});
});
};
const dragleave = (root, clients) => e => {
if (initialTarget !== e.target) {
return;
}
clients.forEach(client => {
const { onexit } = client;
client.state = null;
onexit(eventPosition(e));
});
};
const createHopper = (scope, validateItems, options) => {
// is now hopper scope
scope.classList.add('filepond--hopper');
// shortcuts
const {
catchesDropsOnPage,
requiresDropOnElement,
filterItems = items => items
} = options;
// create a dnd client
const client = createDragNDropClient(
scope,
catchesDropsOnPage ? document.documentElement : scope,
requiresDropOnElement
);
// current client state
let lastState = '';
let currentState = '';
// determines if a file may be dropped
client.allowdrop = items => {
// TODO: if we can, throw error to indicate the items cannot by dropped
return validateItems(filterItems(items));
};
client.ondrop = (position, items) => {
const filteredItems = filterItems(items);
if (!validateItems(filteredItems)) {
api.ondragend(position);
return;
}
currentState = 'drag-drop';
api.onload(filteredItems, position);
};
client.ondrag = position => {
api.ondrag(position);
};
client.onenter = position => {
currentState = 'drag-over';
api.ondragstart(position);
};
client.onexit = position => {
currentState = 'drag-exit';
api.ondragend(position);
};
const api = {
updateHopperState: () => {
if (lastState !== currentState) {
scope.dataset.hopperState = currentState;
lastState = currentState;
}
},
onload: () => {},
ondragstart: () => {},
ondrag: () => {},
ondragend: () => {},
destroy: () => {
// destroy client
client.destroy();
}
};
return api;
};
let listening = false;
const listeners$1 = [];
const handlePaste = e => {
requestDataTransferItems(e.clipboardData).then(files => {
// no files received
if (!files.length) {
return;
}
// notify listeners of received files
listeners$1.forEach(listener => listener(files));
});
};
const listen = cb => {
// can't add twice
if (listeners$1.includes(cb)) {
return;
}
// add initial listener
listeners$1.push(cb);
// setup paste listener for entire page
if (listening) {
return;
}
listening = true;
document.addEventListener('paste', handlePaste);
};
const unlisten = listener => {
arrayRemove(listeners$1, listeners$1.indexOf(listener));
// clean up
if (listeners$1.length === 0) {
document.removeEventListener('paste', handlePaste);
listening = false;
}
};
const createPaster = () => {
const cb = files => {
api.onload(files);
};
const api = {
destroy: () => {
unlisten(cb);
},
onload: () => {}
};
listen(cb);
return api;
};
/**
* Creates the file view
*/
const create$c = ({ root, props }) => {
root.element.id = `filepond--assistant-${props.id}`;
attr(root.element, 'role', 'status');
attr(root.element, 'aria-live', 'polite');
attr(root.element, 'aria-relevant', 'additions');
};
let addFilesNotificationTimeout = null;
let notificationClearTimeout = null;
const filenames = [];
const assist = (root, message) => {
root.element.textContent = message;
};
const clear$1 = root => {
root.element.textContent = '';
};
const listModified = (root, filename, label) => {
const total = root.query('GET_TOTAL_ITEMS');
assist(
root,
`${label} ${filename}, ${total} ${
total === 1
? root.query('GET_LABEL_FILE_COUNT_SINGULAR')
: root.query('GET_LABEL_FILE_COUNT_PLURAL')
}`
);
// clear group after set amount of time so the status is not read twice
clearTimeout(notificationClearTimeout);
notificationClearTimeout = setTimeout(() => {
clear$1(root);
}, 1500);
};
const isUsingFilePond = root =>
root.element.parentNode.contains(document.activeElement);
const itemAdded = ({ root, action }) => {
if (!isUsingFilePond(root)) {
return;
}
root.element.textContent = '';
const item = root.query('GET_ITEM', action.id);
filenames.push(item.filename);
clearTimeout(addFilesNotificationTimeout);
addFilesNotificationTimeout = setTimeout(() => {
listModified(
root,
filenames.join(', '),
root.query('GET_LABEL_FILE_ADDED')
);
filenames.length = 0;
}, 750);
};
const itemRemoved = ({ root, action }) => {
if (!isUsingFilePond(root)) {
return;
}
const item = action.item;
listModified(root, item.filename, root.query('GET_LABEL_FILE_REMOVED'));
};
const itemProcessed = ({ root, action }) => {
// will also notify the user when FilePond is not being used, as the user might be occupied with other activities while uploading a file
const item = root.query('GET_ITEM', action.id);
const filename = item.filename;
const label = root.query('GET_LABEL_FILE_PROCESSING_COMPLETE');
assist(root, `${filename} ${label}`);
};
const itemProcessedUndo = ({ root, action }) => {
const item = root.query('GET_ITEM', action.id);
const filename = item.filename;
const label = root.query('GET_LABEL_FILE_PROCESSING_ABORTED');
assist(root, `${filename} ${label}`);
};
const itemError = ({ root, action }) => {
const item = root.query('GET_ITEM', action.id);
const filename = item.filename;
// will also notify the user when FilePond is not being used, as the user might be occupied with other activities while uploading a file
assist(root, `${action.status.main} ${filename} ${action.status.sub}`);
};
const assistant = createView({
create: create$c,
ignoreRect: true,
ignoreRectUpdate: true,
write: createRoute({
DID_LOAD_ITEM: itemAdded,
DID_REMOVE_ITEM: itemRemoved,
DID_COMPLETE_ITEM_PROCESSING: itemProcessed,
DID_ABORT_ITEM_PROCESSING: itemProcessedUndo,
DID_REVERT_ITEM_PROCESSING: itemProcessedUndo,
DID_THROW_ITEM_REMOVE_ERROR: itemError,
DID_THROW_ITEM_LOAD_ERROR: itemError,
DID_THROW_ITEM_INVALID: itemError,
DID_THROW_ITEM_PROCESSING_ERROR: itemError
}),
tag: 'span',
name: 'assistant'
});
const toCamels = (string, separator = '-') =>
string.replace(new RegExp(`${separator}.`, 'g'), sub =>
sub.charAt(1).toUpperCase()
);
const debounce = (func, interval = 16, immidiateOnly = true) => {
let last = Date.now();
let timeout = null;
return (...args) => {
clearTimeout(timeout);
const dist = Date.now() - last;
const fn = () => {
last = Date.now();
func(...args);
};
if (dist < interval) {
// we need to delay by the difference between interval and dist
// for example: if distance is 10 ms and interval is 16 ms,
// we need to wait an additional 6ms before calling the function)
if (!immidiateOnly) {
timeout = setTimeout(fn, interval - dist);
}
} else {
// go!
fn();
}
};
};
const MAX_FILES_LIMIT = 1000000;
const create$d = ({ root, props }) => {
// Add id
const id = root.query('GET_ID');
if (id) {
root.element.id = id;
}
// Add className
const className = root.query('GET_CLASS_NAME');
if (className) {
className.split(' ').forEach(name => {
root.element.classList.add(name);
});
}
// Field label
root.ref.label = root.appendChildView(
root.createChildView(dropLabel, {
...props,
translateY: null,
caption: root.query('GET_LABEL_IDLE')
})
);
// List of items
root.ref.list = root.appendChildView(
root.createChildView(listScroller, { translateY: null })
);
// Background panel
root.ref.panel = root.appendChildView(
root.createChildView(panel, { name: 'panel-root' })
);
// Assistant notifies assistive tech when content changes
root.ref.assistant = root.appendChildView(
root.createChildView(assistant, { ...props })
);
// Measure (tests if fixed height was set)
// DOCTYPE needs to be set for this to work
root.ref.measure = createElement$1('div');
root.ref.measure.style.height = '100%';
root.element.appendChild(root.ref.measure);
// information on the root height or fixed height status
root.ref.bounds = null;
// apply initial style properties
root
.query('GET_STYLES')
.filter(style => !isEmpty(style.value))
.map(({ name, value }) => {
root.element.dataset[name] = value;
});
// determine if width changed
root.ref.widthPrevious = null;
root.ref.widthUpdated = debounce(() => {
root.ref.updateHistory = [];
root.dispatch('DID_RESIZE_ROOT');
}, 250);
// history of updates
root.ref.previousAspectRatio = null;
root.ref.updateHistory = [];
};
const write$8 = ({ root, props, actions }) => {
// route actions
route$5({ root, props, actions });
// apply style properties
actions
.filter(action => /^DID_SET_STYLE_/.test(action.type))
.filter(action => !isEmpty(action.data.value))
.map(({ type, data }) => {
const name = toCamels(type.substr(8).toLowerCase(), '_');
root.element.dataset[name] = data.value;
root.invalidateLayout();
});
if (root.rect.element.hidden) return;
if (root.rect.element.width !== root.ref.widthPrevious) {
root.ref.widthPrevious = root.rect.element.width;
root.ref.widthUpdated();
}
// get box bounds, we do this only once
let bounds = root.ref.bounds;
if (!bounds) {
bounds = root.ref.bounds = calculateRootBoundingBoxHeight(root);
// destroy measure element
root.element.removeChild(root.ref.measure);
root.ref.measure = null;
}
// get quick references to various high level parts of the upload tool
const { hopper, label, list, panel } = root.ref;
// sets correct state to hopper scope
if (hopper) {
hopper.updateHopperState();
}
// bool to indicate if we're full or not
const aspectRatio = root.query('GET_PANEL_ASPECT_RATIO');
const isMultiItem = root.query('GET_ALLOW_MULTIPLE');
const totalItems = root.query('GET_TOTAL_ITEMS');
const maxItems = isMultiItem
? root.query('GET_MAX_FILES') || MAX_FILES_LIMIT
: 1;
const atMaxCapacity = totalItems === maxItems;
// action used to add item
const addAction = actions.find(action => action.type === 'DID_ADD_ITEM');
// if reached max capacity and we've just reached it
if (atMaxCapacity && addAction) {
// get interaction type
const interactionMethod = addAction.data.interactionMethod;
// hide label
label.opacity = 0;
if (isMultiItem) {
label.translateY = -40;
} else {
if (interactionMethod === InteractionMethod.API) {
label.translateX = 40;
} else if (interactionMethod === InteractionMethod.BROWSE) {
label.translateY = 40;
} else {
label.translateY = 30;
}
}
} else if (!atMaxCapacity) {
label.opacity = 1;
label.translateX = 0;
label.translateY = 0;
}
const listItemMargin = calculateListItemMargin(root);
const listHeight = calculateListHeight(root);
const labelHeight = label.rect.element.height;
const currentLabelHeight = !isMultiItem || atMaxCapacity ? 0 : labelHeight;
const listMarginTop = atMaxCapacity ? list.rect.element.marginTop : 0;
const listMarginBottom =
totalItems === 0 ? 0 : list.rect.element.marginBottom;
const visualHeight =
currentLabelHeight + listMarginTop + listHeight.visual + listMarginBottom;
const boundsHeight =
currentLabelHeight + listMarginTop + listHeight.bounds + listMarginBottom;
// link list to label bottom position
list.translateY =
Math.max(0, currentLabelHeight - list.rect.element.marginTop) -
listItemMargin.top;
if (aspectRatio) {
// fixed aspect ratio
// calculate height based on width
const width = root.rect.element.width;
const height = width * aspectRatio;
// clear history if aspect ratio has changed
if (aspectRatio !== root.ref.previousAspectRatio) {
root.ref.previousAspectRatio = aspectRatio;
root.ref.updateHistory = [];
}
// remember this width
const history = root.ref.updateHistory;
history.push(width);
const MAX_BOUNCES = 2;
if (history.length > MAX_BOUNCES * 2) {
const l = history.length;
const bottom = l - 10;
let bounces = 0;
for (let i = l; i >= bottom; i--) {
if (history[i] === history[i - 2]) {
bounces++;
}
if (bounces >= MAX_BOUNCES) {
// dont adjust height
return;
}
}
}
// fix height of panel so it adheres to aspect ratio
panel.scalable = false;
panel.height = height;
// available height for list
const listAvailableHeight =
// the height of the panel minus the label height
height -
currentLabelHeight -
// the room we leave open between the end of the list and the panel bottom
(listMarginBottom - listItemMargin.bottom) -
// if we're full we need to leave some room between the top of the panel and the list
(atMaxCapacity ? listMarginTop : 0);
if (listHeight.visual > listAvailableHeight) {
list.overflow = listAvailableHeight;
} else {
list.overflow = null;
}
// set container bounds (so pushes siblings downwards)
root.height = height;
} else if (bounds.fixedHeight) {
// fixed height
// fix height of panel
panel.scalable = false;
// available height for list
const listAvailableHeight =
// the height of the panel minus the label height
bounds.fixedHeight -
currentLabelHeight -
// the room we leave open between the end of the list and the panel bottom
(listMarginBottom - listItemMargin.bottom) -
// if we're full we need to leave some room between the top of the panel and the list
(atMaxCapacity ? listMarginTop : 0);
// set list height
if (listHeight.visual > listAvailableHeight) {
list.overflow = listAvailableHeight;
} else {
list.overflow = null;
}
// no need to set container bounds as these are handles by CSS fixed height
} else if (bounds.cappedHeight) {
// max-height
// not a fixed height panel
const isCappedHeight = visualHeight >= bounds.cappedHeight;
const panelHeight = Math.min(bounds.cappedHeight, visualHeight);
panel.scalable = true;
panel.height = isCappedHeight
? panelHeight
: panelHeight - listItemMargin.top - listItemMargin.bottom;
// available height for list
const listAvailableHeight =
// the height of the panel minus the label height
panelHeight -
currentLabelHeight -
// the room we leave open between the end of the list and the panel bottom
(listMarginBottom - listItemMargin.bottom) -
// if we're full we need to leave some room between the top of the panel and the list
(atMaxCapacity ? listMarginTop : 0);
// set list height (if is overflowing)
if (
visualHeight > bounds.cappedHeight &&
listHeight.visual > listAvailableHeight
) {
list.overflow = listAvailableHeight;
} else {
list.overflow = null;
}
// set container bounds (so pushes siblings downwards)
root.height = Math.min(
bounds.cappedHeight,
boundsHeight - listItemMargin.top - listItemMargin.bottom
);
} else {
// flexible height
// not a fixed height panel
const itemMargin =
totalItems > 0 ? listItemMargin.top + listItemMargin.bottom : 0;
panel.scalable = true;
panel.height = Math.max(labelHeight, visualHeight - itemMargin);
// set container bounds (so pushes siblings downwards)
root.height = Math.max(labelHeight, boundsHeight - itemMargin);
}
};
const calculateListItemMargin = root => {
const item = root.ref.list.childViews[0].childViews[0];
return item
? {
top: item.rect.element.marginTop,
bottom: item.rect.element.marginBottom
}
: {
top: 0,
bottom: 0
};
};
const calculateListHeight = root => {
let visual = 0;
let bounds = 0;
// get file list reference
const scrollList = root.ref.list;
const itemList = scrollList.childViews[0];
const children = itemList.childViews;
// no children, done!
if (children.length === 0) return { visual, bounds };
const horizontalSpace = itemList.rect.element.width;
const dragIndex = getItemIndexByPosition(
itemList,
scrollList.dragCoordinates
);
const childRect = children[0].rect.element;
const itemVerticalMargin = childRect.marginTop + childRect.marginBottom;
const itemHorizontalMargin = childRect.marginLeft + childRect.marginRight;
const itemWidth = childRect.width + itemHorizontalMargin;
const itemHeight = childRect.height + itemVerticalMargin;
const newItem = typeof dragIndex !== 'undefined' && dragIndex >= 0 ? 1 : 0;
const removedItem = children.find(
child => child.markedForRemoval && child.opacity < 0.45
)
? -1
: 0;
const verticalItemCount = children.length + newItem + removedItem;
const itemsPerRow = Math.round(horizontalSpace / itemWidth);
// stack
if (itemsPerRow === 1) {
children.forEach(item => {
const height = item.rect.element.height + itemVerticalMargin;
bounds += height;
visual += height * item.opacity;
});
}
// grid
else {
bounds = Math.ceil(verticalItemCount / itemsPerRow) * itemHeight;
visual = bounds;
}
return { visual, bounds };
};
const calculateRootBoundingBoxHeight = root => {
const height = root.ref.measureHeight || null;
const cappedHeight = parseInt(root.style.maxHeight, 10) || null;
const fixedHeight = height === 0 ? null : height;
return {
cappedHeight,
fixedHeight
};
};
const exceedsMaxFiles = (root, items) => {
const allowReplace = root.query('GET_ALLOW_REPLACE');
const allowMultiple = root.query('GET_ALLOW_MULTIPLE');
const totalItems = root.query('GET_TOTAL_ITEMS');
let maxItems = root.query('GET_MAX_FILES');
// total amount of items being dragged
const totalBrowseItems = items.length;
// if does not allow multiple items and dragging more than one item
if (!allowMultiple && totalBrowseItems > 1) {
return true;
}
// limit max items to one if not allowed to drop multiple items
maxItems = allowMultiple ? maxItems : allowReplace ? maxItems : 1;
// no more room?
const hasMaxItems = isInt(maxItems);
if (hasMaxItems && totalItems + totalBrowseItems > maxItems) {
root.dispatch('DID_THROW_MAX_FILES', {
source: items,
error: createResponse('warning', 0, 'Max files')
});
return true;
}
return false;
};
const getDragIndex = (list, position) => {
const itemList = list.childViews[0];
return getItemIndexByPosition(itemList, {
left: position.scopeLeft - itemList.rect.element.left,
top:
position.scopeTop -
(list.rect.outer.top +
list.rect.element.marginTop +
list.rect.element.scrollTop)
});
};
/**
* Enable or disable file drop functionality
*/
const toggleDrop = root => {
const isAllowed = root.query('GET_ALLOW_DROP');
const isDisabled = root.query('GET_DISABLED');
const enabled = isAllowed && !isDisabled;
if (enabled && !root.ref.hopper) {
const hopper = createHopper(
root.element,
items => {
// these files don't fit so stop here
if (exceedsMaxFiles(root, items)) return false;
// allow quick validation of dropped items
const beforeDropFile =
root.query('GET_BEFORE_DROP_FILE') || (() => true);
// all items should be validated by all filters as valid
const dropValidation = root.query('GET_DROP_VALIDATION');
return dropValidation
? items.every(
item =>
applyFilters('ALLOW_HOPPER_ITEM', item, {
query: root.query
}).every(result => result === true) && beforeDropFile(item)
)
: true;
},
{
filterItems: items => {
const ignoredFiles = root.query('GET_IGNORED_FILES');
return items.filter(item => {
if (isFile(item)) {
return !ignoredFiles.includes(item.name.toLowerCase());
}
return true;
});
},
catchesDropsOnPage: root.query('GET_DROP_ON_PAGE'),
requiresDropOnElement: root.query('GET_DROP_ON_ELEMENT')
}
);
hopper.onload = (items, position) => {
root.dispatch('ADD_ITEMS', {
items,
index: getDragIndex(root.ref.list, position),
interactionMethod: InteractionMethod.DROP
});
root.dispatch('DID_DROP', { position });
root.dispatch('DID_END_DRAG', { position });
};
hopper.ondragstart = position => {
root.dispatch('DID_START_DRAG', { position });
};
hopper.ondrag = debounce(position => {
root.dispatch('DID_DRAG', { position });
});
hopper.ondragend = position => {
root.dispatch('DID_END_DRAG', { position });
};
root.ref.hopper = hopper;
root.ref.drip = root.appendChildView(root.createChildView(drip));
} else if (!enabled && root.ref.hopper) {
root.ref.hopper.destroy();
root.ref.hopper = null;
root.removeChildView(root.ref.drip);
}
};
/**
* Enable or disable browse functionality
*/
const toggleBrowse = (root, props) => {
const isAllowed = root.query('GET_ALLOW_BROWSE');
const isDisabled = root.query('GET_DISABLED');
const enabled = isAllowed && !isDisabled;
if (enabled && !root.ref.browser) {
root.ref.browser = root.appendChildView(
root.createChildView(browser, {
...props,
onload: items => {
// these files don't fit so stop here
if (exceedsMaxFiles(root, items)) return false;
// add items!
root.dispatch('ADD_ITEMS', {
items,
index: -1,
interactionMethod: InteractionMethod.BROWSE
});
}
}),
0
);
} else if (!enabled && root.ref.browser) {
root.removeChildView(root.ref.browser);
root.ref.browser = null;
}
};
/**
* Enable or disable paste functionality
*/
const togglePaste = root => {
const isAllowed = root.query('GET_ALLOW_PASTE');
const isDisabled = root.query('GET_DISABLED');
const enabled = isAllowed && !isDisabled;
if (enabled && !root.ref.paster) {
root.ref.paster = createPaster();
root.ref.paster.onload = items => {
root.dispatch('ADD_ITEMS', {
items,
index: -1,
interactionMethod: InteractionMethod.PASTE
});
};
} else if (!enabled && root.ref.paster) {
root.ref.paster.destroy();
root.ref.paster = null;
}
};
/**
* Route actions
*/
const route$5 = createRoute({
DID_SET_ALLOW_BROWSE: ({ root, props }) => {
toggleBrowse(root, props);
},
DID_SET_ALLOW_DROP: ({ root }) => {
toggleDrop(root);
},
DID_SET_ALLOW_PASTE: ({ root }) => {
togglePaste(root);
},
DID_SET_DISABLED: ({ root, props }) => {
toggleDrop(root);
togglePaste(root);
toggleBrowse(root, props);
const isDisabled = root.query('GET_DISABLED');
if (isDisabled) {
root.element.dataset.disabled = 'disabled';
} else {
delete root.element.dataset.disabled;
}
}
});
const root = createView({
name: 'root',
read: ({ root }) => {
if (root.ref.measure) {
root.ref.measureHeight = root.ref.measure.offsetHeight;
}
},
create: create$d,
write: write$8,
destroy: ({ root }) => {
if (root.ref.paster) {
root.ref.paster.destroy();
}
if (root.ref.hopper) {
root.ref.hopper.destroy();
}
},
mixins: {
styles: ['height']
}
});
// creates the app
const createApp = (initialOptions = {}) => {
// let element
let originalElement = null;
// get default options
const defaultOptions = getOptions();
// create the data store, this will contain all our app info
const store = createStore(
// initial state (should be serializable)
createInitialState(defaultOptions),
// queries
[queries, createOptionQueries(defaultOptions)],
// action handlers
[actions, createOptionActions(defaultOptions)]
);
// set initial options
store.dispatch('SET_OPTIONS', { options: initialOptions });
// kick thread if visibility changes
const visibilityHandler = () => {
if (document.hidden) return;
store.dispatch('KICK');
};
document.addEventListener('visibilitychange', visibilityHandler);
// re-render on window resize start and finish
let resizeDoneTimer = null;
let isResizing = false;
let isResizingHorizontally = false;
let initialWindowWidth = null;
let currentWindowWidth = null;
const resizeHandler = () => {
if (!isResizing) {
isResizing = true;
}
clearTimeout(resizeDoneTimer);
resizeDoneTimer = setTimeout(() => {
isResizing = false;
initialWindowWidth = null;
currentWindowWidth = null;
if (isResizingHorizontally) {
isResizingHorizontally = false;
store.dispatch('DID_STOP_RESIZE');
}
}, 500);
};
window.addEventListener('resize', resizeHandler);
// render initial view
const view = root(store, { id: getUniqueId() });
//
// PRIVATE API -------------------------------------------------------------------------------------
//
let isResting = false;
let isHidden = false;
const readWriteApi = {
// necessary for update loop
/**
* Reads from dom (never call manually)
* @private
*/
_read: () => {
// test if we're resizing horizontally
// TODO: see if we can optimize this by measuring root rect
if (isResizing) {
currentWindowWidth = window.innerWidth;
if (!initialWindowWidth) {
initialWindowWidth = currentWindowWidth;
}
if (
!isResizingHorizontally &&
currentWindowWidth !== initialWindowWidth
) {
store.dispatch('DID_START_RESIZE');
isResizingHorizontally = true;
}
}
if (isHidden && isResting) {
// test if is no longer hidden
isResting = view.element.offsetParent === null;
}
// if resting, no need to read as numbers will still all be correct
if (isResting) return;
// read view data
view._read();
// if is hidden we need to know so we exit rest mode when revealed
isHidden = view.rect.element.hidden;
},
/**
* Writes to dom (never call manually)
* @private
*/
_write: ts => {
// get all actions from store
const actions = store
.processActionQueue()
// filter out set actions (these will automatically trigger DID_SET)
.filter(action => !/^SET_/.test(action.type));
// if was idling and no actions stop here
if (isResting && !actions.length) return;
// some actions might trigger events
routeActionsToEvents(actions);
// update the view
isResting = view._write(ts, actions, isResizingHorizontally);
// will clean up all archived items
removeReleasedItems(store.query('GET_ITEMS'));
// now idling
if (isResting) {
store.processDispatchQueue();
}
}
};
//
// EXPOSE EVENTS -------------------------------------------------------------------------------------
//
const createEvent = name => data => {
// create default event
const event = {
type: name
};
// no data to add
if (!data) {
return event;
}
// copy relevant props
if (data.hasOwnProperty('error')) {
event.error = data.error ? { ...data.error } : null;
}
if (data.status) {
event.status = { ...data.status };
}
if (data.file) {
event.output = data.file;
}
// only source is available, else add item if possible
if (data.source) {
event.file = data.source;
} else if (data.item || data.id) {
const item = data.item ? data.item : store.query('GET_ITEM', data.id);
event.file = item ? createItemAPI(item) : null;
}
// map all items in a possible items array
if (data.items) {
event.items = data.items.map(createItemAPI);
}
// if this is a progress event add the progress amount
if (/progress/.test(name)) {
event.progress = data.progress;
}
return event;
};
const eventRoutes = {
DID_DESTROY: createEvent('destroy'),
DID_INIT: createEvent('init'),
DID_THROW_MAX_FILES: createEvent('warning'),
DID_START_ITEM_LOAD: createEvent('addfilestart'),
DID_UPDATE_ITEM_LOAD_PROGRESS: createEvent('addfileprogress'),
DID_LOAD_ITEM: createEvent('addfile'),
DID_THROW_ITEM_INVALID: [createEvent('error'), createEvent('addfile')],
DID_THROW_ITEM_LOAD_ERROR: [createEvent('error'), createEvent('addfile')],
DID_THROW_ITEM_REMOVE_ERROR: [
createEvent('error'),
createEvent('removefile')
],
DID_PREPARE_OUTPUT: createEvent('preparefile'),
DID_START_ITEM_PROCESSING: createEvent('processfilestart'),
DID_UPDATE_ITEM_PROCESS_PROGRESS: createEvent('processfileprogress'),
DID_ABORT_ITEM_PROCESSING: createEvent('processfileabort'),
DID_COMPLETE_ITEM_PROCESSING: createEvent('processfile'),
DID_COMPLETE_ITEM_PROCESSING_ALL: createEvent('processfiles'),
DID_REVERT_ITEM_PROCESSING: createEvent('processfilerevert'),
DID_THROW_ITEM_PROCESSING_ERROR: [
createEvent('error'),
createEvent('processfile')
],
DID_REMOVE_ITEM: createEvent('removefile'),
DID_UPDATE_ITEMS: createEvent('updatefiles'),
DID_ACTIVATE_ITEM: createEvent('activatefile')
};
const exposeEvent = event => {
// create event object to be dispatched
const detail = { pond: exports, ...event };
delete detail.type;
view.element.dispatchEvent(
new CustomEvent(`FilePond:${event.type}`, {
// event info
detail,
// event behaviour
bubbles: true,
cancelable: true,
composed: true // triggers listeners outside of shadow root
})
);
// event object to params used for `on()` event handlers and callbacks `oninit()`
const params = [];
// if is possible error event, make it the first param
if (event.hasOwnProperty('error')) {
params.push(event.error);
}
// file is always section
if (event.hasOwnProperty('file')) {
params.push(event.file);
}
// append other props
const filtered = ['type', 'error', 'file'];
Object.keys(event)
.filter(key => !filtered.includes(key))
.forEach(key => params.push(event[key]));
// on(type, () => { })
exports.fire(event.type, ...params);
// oninit = () => {}
const handler = store.query(`GET_ON${event.type.toUpperCase()}`);
if (handler) {
handler(...params);
}
};
const routeActionsToEvents = actions => {
if (!actions.length) {
return;
}
actions.forEach(action => {
if (!eventRoutes[action.type]) {
return;
}
const routes = eventRoutes[action.type];
(Array.isArray(routes) ? routes : [routes]).forEach(route => {
setTimeout(() => {
exposeEvent(route(action.data));
}, 0);
});
});
};
//
// PUBLIC API -------------------------------------------------------------------------------------
//
const setOptions = options => store.dispatch('SET_OPTIONS', { options });
const getFile = query => store.query('GET_ACTIVE_ITEM', query);
const addFile = (source, options = {}) =>
new Promise((resolve, reject) => {
addFiles([{ source, options }], { index: options.index })
.then(items => resolve(items && items[0]))
.catch(reject);
});
const removeFile = query => {
// request item removal
store.dispatch('REMOVE_ITEM', { query });
// see if item has been removed
return store.query('GET_ACTIVE_ITEM', query) === null;
};
const addFiles = (...args) =>
new Promise((resolve, reject) => {
const sources = [];
const options = {};
// user passed a sources array
if (isArray(args[0])) {
sources.push.apply(sources, args[0]);
Object.assign(options, args[1] || {});
} else {
// user passed sources as arguments, last one might be options object
const lastArgument = args[args.length - 1];
if (
typeof lastArgument === 'object' &&
!(lastArgument instanceof Blob)
) {
Object.assign(options, args.pop());
}
// add rest to sources
sources.push(...args);
}
store.dispatch('ADD_ITEMS', {
items: sources,
index: options.index,
interactionMethod: InteractionMethod.API,
success: resolve,
failure: reject
});
});
const getFiles = () => store.query('GET_ACTIVE_ITEMS');
const processFile = query =>
new Promise((resolve, reject) => {
store.dispatch('REQUEST_ITEM_PROCESSING', {
query,
success: item => {
resolve(item);
},
failure: error => {
reject(error);
}
});
});
const processFiles = (...args) => {
const queries = Array.isArray(args[0]) ? args[0] : args;
if (!queries.length) {
const files = getFiles().filter(
item =>
!(
item.status === ItemStatus.IDLE && item.origin === FileOrigin.LOCAL
) &&
item.status !== ItemStatus.PROCESSING &&
item.status !== ItemStatus.PROCESSING_COMPLETE &&
item.status !== ItemStatus.PROCESSING_REVERT_ERROR
);
return Promise.all(files.map(processFile));
}
return Promise.all(queries.map(processFile));
};
const removeFiles = (...args) => {
const queries = Array.isArray(args[0]) ? args[0] : args;
const files = getFiles();
if (!queries.length) {
return Promise.all(files.map(removeFile));
}
// when removing by index the indexes shift after each file removal so we need to convert indexes to ids
const mappedQueries = queries
.map(query =>
isNumber(query) ? (files[query] ? files[query].id : null) : query
)
.filter(query => query);
return mappedQueries.map(removeFile);
};
const exports = {
// supports events
...on(),
// inject private api methods
...readWriteApi,
// inject all getters and setters
...createOptionAPI(store, defaultOptions),
/**
* Override options defined in options object
* @param options
*/
setOptions,
/**
* Load the given file
* @param source - the source of the file (either a File, base64 data uri or url)
* @param options - object, { index: 0 }
*/
addFile,
/**
* Load the given files
* @param sources - the sources of the files to load
* @param options - object, { index: 0 }
*/
addFiles,
/**
* Returns the file objects matching the given query
* @param query { string, number, null }
*/
getFile,
/**
* Upload file with given name
* @param query { string, number, null }
*/
processFile,
/**
* Removes a file by its name
* @param query { string, number, null }
*/
removeFile,
/**
* Returns all files (wrapped in public api)
*/
getFiles,
/**
* Starts uploading all files
*/
processFiles,
/**
* Clears all files from the files list
*/
removeFiles,
/**
* Sort list of files
*/
sort: compare => store.dispatch('SORT', { compare }),
/**
* Browse the file system for a file
*/
browse: () => {
// needs to be trigger directly as user action needs to be traceable (is not traceable in requestAnimationFrame)
var input = view.element.querySelector('input[type=file]');
if (input) {
input.click();
}
},
/**
* Destroys the app
*/
destroy: () => {
// request destruction
exports.fire('destroy', view.element);
// stop active processes (file uploads, fetches, stuff like that)
// loop over items and depending on states call abort for ongoing processes
store.dispatch('ABORT_ALL');
// destroy view
view._destroy();
// stop listening to resize
window.removeEventListener('resize', resizeHandler);
// stop listening to the visiblitychange event
document.removeEventListener('visibilitychange', visibilityHandler);
// dispatch destroy
store.dispatch('DID_DESTROY');
},
/**
* Inserts the plugin before the target element
*/
insertBefore: element => insertBefore(view.element, element),
/**
* Inserts the plugin after the target element
*/
insertAfter: element => insertAfter(view.element, element),
/**
* Appends the plugin to the target element
*/
appendTo: element => element.appendChild(view.element),
/**
* Replaces an element with the app
*/
replaceElement: element => {
// insert the app before the element
insertBefore(view.element, element);
// remove the original element
element.parentNode.removeChild(element);
// remember original element
originalElement = element;
},
/**
* Restores the original element
*/
restoreElement: () => {
if (!originalElement) {
return; // no element to restore
}
// restore original element
insertAfter(originalElement, view.element);
// remove our element
view.element.parentNode.removeChild(view.element);
// remove reference
originalElement = null;
},
/**
* Returns true if the app root is attached to given element
* @param element
*/
isAttachedTo: element =>
view.element === element || originalElement === element,
/**
* Returns the root element
*/
element: {
get: () => view.element
},
/**
* Returns the current pond status
*/
status: {
get: () => store.query('GET_STATUS')
}
};
// Done!
store.dispatch('DID_INIT');
// create actual api object
return createObject(exports);
};
const createAppObject = (customOptions = {}) => {
// default options
const defaultOptions = {};
forin(getOptions(), (key, value) => {
defaultOptions[key] = value[0];
});
// set app options
const app = createApp({
// default options
...defaultOptions,
// custom options
...customOptions
});
// return the plugin instance
return app;
};
const lowerCaseFirstLetter = string =>
string.charAt(0).toLowerCase() + string.slice(1);
const attributeNameToPropertyName = attributeName =>
toCamels(attributeName.replace(/^data-/, ''));
const mapObject = (object, propertyMap) => {
// remove unwanted
forin(propertyMap, (selector, mapping) => {
forin(object, (property, value) => {
// create regexp shortcut
const selectorRegExp = new RegExp(selector);
// tests if
const matches = selectorRegExp.test(property);
// no match, skip
if (!matches) {
return;
}
// if there's a mapping, the original property is always removed
delete object[property];
// should only remove, we done!
if (mapping === false) {
return;
}
// move value to new property
if (isString(mapping)) {
object[mapping] = value;
return;
}
// move to group
const group = mapping.group;
if (isObject(mapping) && !object[group]) {
object[group] = {};
}
object[group][
lowerCaseFirstLetter(property.replace(selectorRegExp, ''))
] = value;
});
// do submapping
if (mapping.mapping) {
mapObject(object[mapping.group], mapping.mapping);
}
});
};
const getAttributesAsObject = (node, attributeMapping = {}) => {
// turn attributes into object
const attributes = [];
forin(node.attributes, index => {
attributes.push(node.attributes[index]);
});
const output = attributes
.filter(attribute => attribute.name)
.reduce((obj, attribute) => {
const value = attr(node, attribute.name);
obj[attributeNameToPropertyName(attribute.name)] =
value === attribute.name ? true : value;
return obj;
}, {});
// do mapping of object properties
mapObject(output, attributeMapping);
return output;
};
const createAppAtElement = (element, options = {}) => {
// how attributes of the input element are mapped to the options for the plugin
const attributeMapping = {
// translate to other name
'^class$': 'className',
'^multiple$': 'allowMultiple',
'^capture$': 'captureMethod',
// group under single property
'^server': {
group: 'server',
mapping: {
'^process': {
group: 'process'
},
'^revert': {
group: 'revert'
},
'^fetch': {
group: 'fetch'
},
'^restore': {
group: 'restore'
},
'^load': {
group: 'load'
}
}
},
// don't include in object
'^type$': false,
'^files$': false
};
// add additional option translators
applyFilters('SET_ATTRIBUTE_TO_OPTION_MAP', attributeMapping);
// create final options object by setting options object and then overriding options supplied on element
const mergedOptions = {
...options
};
const attributeOptions = getAttributesAsObject(
element.nodeName === 'FIELDSET'
? element.querySelector('input[type=file]')
: element,
attributeMapping
);
// merge with options object
Object.keys(attributeOptions).forEach(key => {
if (isObject(attributeOptions[key])) {
if (!isObject(mergedOptions[key])) {
mergedOptions[key] = {};
}
Object.assign(mergedOptions[key], attributeOptions[key]);
} else {
mergedOptions[key] = attributeOptions[key];
}
});
// if parent is a fieldset, get files from parent by selecting all input fields that are not file upload fields
// these will then be automatically set to the initial files
mergedOptions.files = (options.files || []).concat(
Array.from(element.querySelectorAll('input:not([type=file])')).map(
input => ({
source: input.value,
options: {
type: input.dataset.type
}
})
)
);
// build plugin
const app = createAppObject(mergedOptions);
// add already selected files
if (element.files) {
Array.from(element.files).forEach(file => {
app.addFile(file);
});
}
// replace the target element
app.replaceElement(element);
// expose
return app;
};
// if an element is passed, we create the instance at that element, if not, we just create an up object
const createApp$1 = (...args) =>
isNode(args[0]) ? createAppAtElement(...args) : createAppObject(...args);
const PRIVATE_METHODS = ['fire', '_read', '_write'];
const createAppAPI = app => {
const api = {};
copyObjectPropertiesToObject(app, api, PRIVATE_METHODS);
return api;
};
/**
* Replaces placeholders in given string with replacements
* @param string - "Foo {bar}""
* @param replacements - { "bar": 10 }
*/
const replaceInString = (string, replacements) =>
string.replace(/(?:{([a-zA-Z]+)})/g, (match, group) => replacements[group]);
const createWorker = fn => {
const workerBlob = new Blob(['(', fn.toString(), ')()'], {
type: 'application/javascript'
});
const workerURL = URL.createObjectURL(workerBlob);
const worker = new Worker(workerURL);
return {
transfer: (message, cb) => {},
post: (message, cb, transferList) => {
const id = getUniqueId();
worker.onmessage = e => {
if (e.data.id === id) {
cb(e.data.message);
}
};
worker.postMessage(
{
id,
message
},
transferList
);
},
terminate: () => {
worker.terminate();
URL.revokeObjectURL(workerURL);
}
};
};
const loadImage = url =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve(img);
};
img.onerror = e => {
reject(e);
};
img.src = url;
});
const renameFile = (file, name) => {
const renamedFile = file.slice(0, file.size, file.type);
renamedFile.lastModifiedDate = file.lastModifiedDate;
renamedFile.name = name;
return renamedFile;
};
const copyFile = file => renameFile(file, file.name);
// already registered plugins (can't register twice)
const registeredPlugins = [];
// pass utils to plugin
const createAppPlugin = plugin => {
// already registered
if (registeredPlugins.includes(plugin)) {
return;
}
// remember this plugin
registeredPlugins.push(plugin);
// setup!
const pluginOutline = plugin({
addFilter,
utils: {
Type,
forin,
isString,
isFile,
toNaturalFileSize,
replaceInString,
getExtensionFromFilename,
getFilenameWithoutExtension,
guesstimateMimeType,
getFileFromBlob,
getFilenameFromURL,
createRoute,
createWorker,
createView,
createItemAPI,
loadImage,
copyFile,
renameFile,
createBlob,
applyFilterChain,
text,
getNumericAspectRatioFromString
},
views: {
fileActionButton
}
});
// add plugin options to default options
extendDefaultOptions(pluginOutline.options);
};
// feature detection used by supported() method
const isOperaMini = () =>
Object.prototype.toString.call(window.operamini) === '[object OperaMini]';
const hasPromises = () => 'Promise' in window;
const hasBlobSlice = () => 'slice' in Blob.prototype;
const hasCreateObjectURL = () =>
'URL' in window && 'createObjectURL' in window.URL;
const hasVisibility = () => 'visibilityState' in document;
const hasTiming = () => 'performance' in window; // iOS 8.x
const isBrowser = () =>
typeof window !== 'undefined' && typeof window.document !== 'undefined';
const supported = (() => {
// Runs immidiately and then remembers result for subsequent calls
const isSupported =
// Has to be a browser
isBrowser() &&
// Can't run on Opera Mini due to lack of everything
!isOperaMini() &&
// Require these APIs to feature detect a modern browser
hasVisibility() &&
hasPromises() &&
hasBlobSlice() &&
hasCreateObjectURL() &&
hasTiming();
return () => isSupported;
})();
/**
* Plugin internal state (over all instances)
*/
const state = {
// active app instances, used to redraw the apps and to find the later
apps: []
};
// plugin name
const name = 'filepond';
/**
* Public Plugin methods
*/
const fn = () => {};
let Status$1 = {};
let FileStatus = {};
let FileOrigin$1 = {};
let OptionTypes = {};
let create$e = fn;
let destroy = fn;
let parse = fn;
let find = fn;
let registerPlugin = fn;
let getOptions$1 = fn;
let setOptions$1 = fn;
// if not supported, no API
if (supported()) {
// start painter and fire load event
createPainter(
() => {
state.apps.forEach(app => app._read());
},
ts => {
state.apps.forEach(app => app._write(ts));
}
);
// fire loaded event so we know when FilePond is available
const dispatch = () => {
// let others know we have area ready
document.dispatchEvent(
new CustomEvent('FilePond:loaded', {
detail: {
supported,
create: create$e,
destroy,
parse,
find,
registerPlugin,
setOptions: setOptions$1
}
})
);
// clean up event
document.removeEventListener('DOMContentLoaded', dispatch);
};
if (document.readyState !== 'loading') {
// move to back of execution queue, FilePond should have been exported by then
setTimeout(() => dispatch(), 0);
} else {
document.addEventListener('DOMContentLoaded', dispatch);
}
// updates the OptionTypes object based on the current options
const updateOptionTypes = () =>
forin(getOptions(), (key, value) => {
OptionTypes[key] = value[1];
});
Status$1 = { ...Status };
FileOrigin$1 = { ...FileOrigin };
FileStatus = { ...ItemStatus };
OptionTypes = {};
updateOptionTypes();
// create method, creates apps and adds them to the app array
create$e = (...args) => {
const app = createApp$1(...args);
app.on('destroy', destroy);
state.apps.push(app);
return createAppAPI(app);
};
// destroys apps and removes them from the app array
destroy = hook => {
// returns true if the app was destroyed successfully
const indexToRemove = state.apps.findIndex(app => app.isAttachedTo(hook));
if (indexToRemove >= 0) {
// remove from apps
const app = state.apps.splice(indexToRemove, 1)[0];
// restore original dom element
app.restoreElement();
return true;
}
return false;
};
// parses the given context for plugins (does not include the context element itself)
parse = context => {
// get all possible hooks
const matchedHooks = Array.from(context.querySelectorAll(`.${name}`));
// filter out already active hooks
const newHooks = matchedHooks.filter(
newHook => !state.apps.find(app => app.isAttachedTo(newHook))
);
// create new instance for each hook
return newHooks.map(hook => create$e(hook));
};
// returns an app based on the given element hook
find = hook => {
const app = state.apps.find(app => app.isAttachedTo(hook));
if (!app) {
return null;
}
return createAppAPI(app);
};
// adds a plugin extension
registerPlugin = (...plugins) => {
// register plugins
plugins.forEach(createAppPlugin);
// update OptionTypes, each plugin might have extended the default options
updateOptionTypes();
};
getOptions$1 = () => {
const opts = {};
forin(getOptions(), (key, value) => {
opts[key] = value[0];
});
return opts;
};
setOptions$1 = opts => {
if (isObject(opts)) {
// update existing plugins
state.apps.forEach(app => {
app.setOptions(opts);
});
// override defaults
setOptions(opts);
}
// return new options
return getOptions$1();
};
}
export {
FileOrigin$1 as FileOrigin,
FileStatus,
OptionTypes,
Status$1 as Status,
create$e as create,
destroy,
find,
getOptions$1 as getOptions,
parse,
registerPlugin,
setOptions$1 as setOptions,
supported
};