';
// the width to consider the 100% zoom level; zoom levels are calculated based
// on this width relative to the actual document width
var DOCUMENT_100_PERCENT_WIDTH = 1024;
var ZOOM_FIT_WIDTH = 'fitwidth',
ZOOM_FIT_HEIGHT = 'fitheight',
ZOOM_AUTO = 'auto',
ZOOM_IN = 'in',
ZOOM_OUT = 'out',
SCROLL_PREVIOUS = 'previous',
SCROLL_NEXT = 'next',
LAYOUT_VERTICAL = 'vertical',
LAYOUT_VERTICAL_SINGLE_COLUMN = 'vertical-single-column',
LAYOUT_HORIZONTAL = 'horizontal',
LAYOUT_PRESENTATION = 'presentation',
LAYOUT_PRESENTATION_TWO_PAGE = 'presentation-two-page',
LAYOUT_TEXT = 'text',
PAGE_STATUS_CONVERTING = 'converting',
PAGE_STATUS_NOT_LOADED = 'not loaded',
PAGE_STATUS_LOADING = 'loading',
PAGE_STATUS_LOADED = 'loaded',
PAGE_STATUS_ERROR = 'error';
var STYLE_PADDING_PREFIX = 'padding-',
STYLE_PADDING_TOP = STYLE_PADDING_PREFIX + 'top',
STYLE_PADDING_RIGHT = STYLE_PADDING_PREFIX + 'right',
STYLE_PADDING_LEFT = STYLE_PADDING_PREFIX + 'left',
STYLE_PADDING_BOTTOM = STYLE_PADDING_PREFIX + 'bottom',
// threshold for removing similar zoom levels (closer to 1 is more similar)
ZOOM_LEVEL_SIMILARITY_THRESHOLD = 0.95,
// threshold for removing similar zoom presets (e.g., auto, fit-width, etc)
ZOOM_LEVEL_PRESETS_SIMILARITY_THRESHOLD = 0.99;
var PAGE_LOAD_INTERVAL = 100, //ms between initiating page loads
MAX_PAGE_LOAD_RANGE = 32,
MAX_PAGE_LOAD_RANGE_MOBILE = 8,
// the delay in ms to wait before triggering preloading after `ready`
READY_TRIGGER_PRELOADING_DELAY = 1000;
/**
* Creates a global method for loading svg text into the proxy svg object
* @NOTE: this function should never be called directly in this context;
* it's converted to a string and encoded into the proxy svg data:url
* @returns {void}
* @private
*/
function PROXY_SVG() {
'use strict';
window.loadSVG = function (svgText) {
var domParser = new window.DOMParser(),
svgDoc = domParser.parseFromString(svgText, 'image/svg+xml'),
svgEl = document.importNode(svgDoc.documentElement, true);
// make sure the svg width/height are explicity set to 100%
svgEl.setAttribute('width', '100%');
svgEl.setAttribute('height', '100%');
if (document.body) {
document.body.appendChild(svgEl);
} else {
document.documentElement.appendChild(svgEl);
}
};
}
// @NOTE: MAX_DATA_URLS is the maximum allowed number of data-urls in svg
// content before we give up and stop rendering them
var SVG_MIME_TYPE = 'image/svg+xml',
HTML_TEMPLATE = '',
SVG_CONTAINER_TEMPLATE = '',
// Embed the svg in an iframe (initialized to about:blank), and inject
// the SVG directly to the iframe window using document.write()
// @NOTE: this breaks images in Safari because [?]
EMBED_STRATEGY_IFRAME_INNERHTML = 1,
// Embed the svg with a data-url
// @NOTE: ff allows direct script access to objects embedded with a data url,
// and this method prevents a throbbing spinner because document.write
// causes a spinner in ff
// @NOTE: NOT CURRENTLY USED - this breaks images in firefox because:
// https://bugzilla.mozilla.org/show_bug.cgi?id=922433
EMBED_STRATEGY_DATA_URL = 2,
// Embed the svg directly in html via inline svg.
// @NOTE: NOT CURRENTLY USED - seems to be slow everywhere, but I'm keeping
// this here because it's very little extra code, and inline SVG might
// be better some day?
EMBED_STRATEGY_INLINE_SVG = 3,
// Embed the svg directly with an object tag; don't replace linked resources
// @NOTE: NOT CURRENTLY USED - this is only here for testing purposes, because
// it works in every browser; it doesn't support query string params
// and causes a spinner
EMBED_STRATEGY_BASIC_OBJECT = 4,
// Embed the svg directly with an img tag; don't replace linked resources
// @NOTE: NOT CURRENTLY USED - this is only here for testing purposes
EMBED_STRATEGY_BASIC_IMG = 5,
// Embed a proxy svg script as an object tag via data:url, which exposes a
// loadSVG method on its contentWindow, then call the loadSVG method directly
// with the svg text as the argument
// @NOTE: only works in firefox because of its security policy on data:uri
EMBED_STRATEGY_DATA_URL_PROXY = 6,
// Embed in a way similar to the EMBED_STRATEGY_DATA_URL_PROXY, but in this
// method we use an iframe initialized to about:blank and embed the proxy
// script before calling loadSVG on the iframe's contentWindow
// @NOTE: this is a workaround for the image issue with EMBED_STRATEGY_IFRAME_INNERHTML
// in safari; it also works in firefox
EMBED_STRATEGY_IFRAME_PROXY = 7,
// Embed in an img tag via data:url, downloading stylesheet separately, and
// injecting it into the data:url of SVG text before embedding
// @NOTE: this method seems to be more performant on IE
EMBED_STRATEGY_DATA_URL_IMG = 8;
/*jshint unused:false*/
if (typeof $ === 'undefined') {
throw new Error('jQuery is required');
}
/**
* The one global object for Crocodoc JavaScript.
* @namespace
*/
var Crocodoc = (function () {
'use strict';
var components = {},
utilities = {};
/**
* Find circular dependencies in component mixins
* @param {string} componentName The component name that is being added
* @param {Array} dependencies Array of component mixin dependencies
* @param {void} path String used to keep track of depencency graph
* @returns {void}
*/
function findCircularDependencies(componentName, dependencies, path) {
var i;
path = path || componentName;
for (i = 0; i < dependencies.length; ++i) {
if (componentName === dependencies[i]) {
throw new Error('Circular dependency detected: ' + path + '->' + dependencies[i]);
} else if (components[dependencies[i]]) {
findCircularDependencies(componentName, components[dependencies[i]].mixins, path + '->' + dependencies[i]);
}
}
}
return {
// Zoom, scroll, page status, layout constants
ZOOM_FIT_WIDTH: ZOOM_FIT_WIDTH,
ZOOM_FIT_HEIGHT: ZOOM_FIT_HEIGHT,
ZOOM_AUTO: ZOOM_AUTO,
ZOOM_IN: ZOOM_IN,
ZOOM_OUT: ZOOM_OUT,
SCROLL_PREVIOUS: SCROLL_PREVIOUS,
SCROLL_NEXT: SCROLL_NEXT,
LAYOUT_VERTICAL: LAYOUT_VERTICAL,
LAYOUT_VERTICAL_SINGLE_COLUMN: LAYOUT_VERTICAL_SINGLE_COLUMN,
LAYOUT_HORIZONTAL: LAYOUT_HORIZONTAL,
LAYOUT_PRESENTATION: LAYOUT_PRESENTATION,
LAYOUT_PRESENTATION_TWO_PAGE: LAYOUT_PRESENTATION_TWO_PAGE,
LAYOUT_TEXT: LAYOUT_TEXT,
// The number of times to retry loading an asset before giving up
ASSET_REQUEST_RETRIES: 1,
// templates exposed to allow more customization
viewerTemplate: VIEWER_HTML_TEMPLATE,
pageTemplate: PAGE_HTML_TEMPLATE,
// exposed for testing purposes only
// should not be accessed directly otherwise
components: components,
utilities: utilities,
/**
* Create and return a viewer instance initialized with the given parameters
* @param {string|Element|jQuery} el The element to bind the viewer to
* @param {Object} config The viewer configuration parameters
* @returns {Object} The viewer instance
*/
createViewer: function (el, config) {
return new Crocodoc.Viewer(el, config);
},
/**
* Get a viewer instance by id
* @param {number} id The id
* @returns {Object} The viewer instance
*/
getViewer: function (id) {
return Crocodoc.Viewer.get(id);
},
/**
* Register a new component
* @param {string} name The (unique) name of the component
* @param {Array} mixins Array of component names to instantiate and pass as mixinable objects to the creator method
* @param {Function} creator Factory function used to create an instance of the component
* @returns {void}
*/
addComponent: function (name, mixins, creator) {
if (mixins instanceof Function) {
creator = mixins;
mixins = [];
}
// make sure this component won't cause a circular mixin dependency
findCircularDependencies(name, mixins);
components[name] = {
mixins: mixins,
creator: creator
};
},
/**
* Create and return an instance of the named component
* @param {string} name The name of the component to create
* @param {Crocodoc.Scope} scope The scope object to create the component on
* @returns {?Object} The component instance or null if the component doesn't exist
*/
createComponent: function (name, scope) {
var component = components[name];
if (component) {
var args = [];
for (var i = 0; i < component.mixins.length; ++i) {
args.push(this.createComponent(component.mixins[i], scope));
}
args.unshift(scope);
return component.creator.apply(component.creator, args);
}
return null;
},
/**
* Register a new Crocodoc plugin
* @param {string} name The (unique) name of the plugin
* @param {Function} creator Factory function used to create an instance of the plugin
* @returns {void}
*/
addPlugin: function (name, creator) {
this.addComponent('plugin-' + name, creator);
},
/**
* Register a new Crocodoc data provider
* @param {string} modelName The model name this data provider provides
* @param {Function} creator Factory function used to create an instance of the data provider.
*/
addDataProvider: function(modelName, creator) {
this.addComponent('data-provider-' + modelName, creator);
},
/**
* Register a new utility
* @param {string} name The (unique) name of the utility
* @param {Function} creator Factory function used to create an instance of the utility
* @returns {void}
*/
addUtility: function (name, creator) {
utilities[name] = {
creator: creator,
instance: null
};
},
/**
* Retrieve the named utility
* @param {string} name The name of the utility to retrieve
* @returns {?Object} The utility or null if the utility doesn't exist
*/
getUtility: function (name) {
var utility = utilities[name];
if (utility) {
if (!utility.instance) {
utility.instance = utility.creator(this);
}
return utility.instance;
}
return null;
}
};
})();
(function () {
'use strict';
/**
* Scope class used for component scoping (creating, destroying, broadcasting messages)
* @constructor
*/
Crocodoc.Scope = function Scope(config) {
//----------------------------------------------------------------------
// Private
//----------------------------------------------------------------------
var util = Crocodoc.getUtility('common');
var instances = [],
messageQueue = [],
dataProviders = {},
ready = false;
/**
* Broadcast a message to all components in this scope that have registered
* a listener for the named message type
* @param {string} messageName The message name
* @param {any} data The message data
* @returns {void}
* @private
*/
function broadcast(messageName, data) {
var i, len, instance, messages;
for (i = 0, len = instances.length; i < len; ++i) {
instance = instances[i];
if (!instance) {
continue;
}
messages = instance.messages || [];
if (util.inArray(messageName, messages) !== -1) {
if (util.isFn(instance.onmessage)) {
instance.onmessage.call(instance, messageName, data);
}
}
}
}
/**
* Broadcasts any (pageavailable) messages that were queued up
* before the viewer was ready
* @returns {void}
* @private
*/
function broadcastQueuedMessages() {
var message;
while (messageQueue.length) {
message = messageQueue.shift();
broadcast(message.name, message.data);
}
messageQueue = null;
}
/**
* Call the destroy method on a component instance if it exists and the
* instance has not already been destroyed
* @param {Object} instance The component instance
* @returns {void}
*/
function destroyComponent(instance) {
if (util.isFn(instance.destroy) && !instance._destroyed) {
instance.destroy();
instance._destroyed = true;
}
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
config.dataProviders = config.dataProviders || {};
/**
* Create and return an instance of the named component,
* and add it to the list of instances in this scope
* @param {string} componentName The name of the component to create
* @returns {?Object} The component instance or null if the component doesn't exist
*/
this.createComponent = function (componentName) {
var instance = Crocodoc.createComponent(componentName, this);
if (instance) {
instance.componentName = componentName;
instances.push(instance);
}
return instance;
};
/**
* Remove and call the destroy method on a component instance
* @param {Object} instance The component instance to remove
* @returns {void}
*/
this.destroyComponent = function (instance) {
var i, len;
for (i = 0, len = instances.length; i < len; ++i) {
if (instance === instances[i]) {
destroyComponent(instance);
instances.splice(i, 1);
break;
}
}
};
/**
* Remove and call the destroy method on all instances in this scope
* @returns {void}
*/
this.destroy = function () {
var i, len, instance,
components = instances.slice();
for (i = 0, len = components.length; i < len; ++i) {
instance = components[i];
destroyComponent(instance);
}
instances = [];
dataProviders = {};
};
/**
* Broadcast a message or queue it until the viewer is ready
* @param {string} name The name of the message
* @param {*} data The message data
* @returns {void}
*/
this.broadcast = function (messageName, data) {
if (ready) {
broadcast(messageName, data);
} else {
messageQueue.push({ name: messageName, data: data });
}
};
/**
* Passthrough method to the framework that retrieves utilities.
* @param {string} name The name of the utility to retrieve
* @returns {?Object} An object if the utility is found or null if not
*/
this.getUtility = function (name) {
return Crocodoc.getUtility(name);
};
/**
* Get the config object associated with this scope
* @returns {Object} The config object
*/
this.getConfig = function () {
return config;
};
/**
* Tell the scope that the viewer is ready and broadcast queued messages
* @returns {void}
*/
this.ready = function () {
if (!ready) {
ready = true;
broadcastQueuedMessages();
}
};
/**
* Get a model object from a data provider. If the objectType is listed
* in config.dataProviders, this will get the value from the data
* provider that is specified in that map instead.
* @param {string} objectType The type of object to retrieve ('page-svg', 'page-text', etc)
* @param {string} objectKey The key of the object to retrieve
* @returns {$.Promise}
*/
this.get = function(objectType, objectKey) {
var newObjectType = config.dataProviders[objectType] || objectType;
var provider = this.getDataProvider(newObjectType);
if (provider) {
return provider.get(objectType, objectKey);
}
return $.Deferred().reject('data-provider not found').promise();
};
/**
* Get an instance of a data provider. Ignores config.dataProviders
* overrides.
* @param {string} objectType The type of object to retrieve a data provider for ('page-svg', 'page-text', etc)
* @returns {Object} The data provider
*/
this.getDataProvider = function (objectType) {
var provider;
if (dataProviders[objectType]) {
provider = dataProviders[objectType];
} else {
provider = this.createComponent('data-provider-' + objectType);
dataProviders[objectType] = provider;
}
return provider;
};
};
})();
(function () {
'use strict';
/**
* Build an event object for the given type and data
* @param {string} type The event type
* @param {Object} data The event data
* @returns {Object} The event object
*/
function buildEventObject(type, data) {
var isDefaultPrevented = false;
return {
type: type,
data: data,
/**
* Prevent the default action for this event
* @returns {void}
*/
preventDefault: function () {
isDefaultPrevented = true;
},
/**
* Return true if preventDefault() has been called on this event
* @returns {Boolean}
*/
isDefaultPrevented: function () {
return isDefaultPrevented;
}
};
}
/**
* An object that is capable of generating custom events and also
* executing handlers for events when they occur.
* @constructor
*/
Crocodoc.EventTarget = function() {
/**
* Map of events to handlers. The keys in the object are the event names.
* The values in the object are arrays of event handler functions.
* @type {Object}
* @private
*/
this._handlers = {};
};
Crocodoc.EventTarget.prototype = {
// restore constructor
constructor: Crocodoc.EventTarget,
/**
* Adds a new event handler for a particular type of event.
* @param {string} type The name of the event to listen for.
* @param {Function} handler The function to call when the event occurs.
* @returns {void}
*/
on: function(type, handler) {
if (typeof this._handlers[type] === 'undefined') {
this._handlers[type] = [];
}
this._handlers[type].push(handler);
},
/**
* Fires an event with the given name and data.
* @param {string} type The type of event to fire.
* @param {Object} data An object with properties that should end up on
* the event object for the given event.
* @returns {Object} The event object
*/
fire: function(type, data) {
var handlers,
i,
len,
event = buildEventObject(type, data);
// if there are handlers for the event, call them in order
handlers = this._handlers[event.type];
if (handlers instanceof Array) {
// @NOTE: do a concat() here to create a copy of the handlers array,
// so that if another handler is removed of the same type, it doesn't
// interfere with the handlers array
handlers = handlers.concat();
for (i = 0, len = handlers.length; i < len; i++) {
if (handlers[i]) {
handlers[i].call(this, event);
}
}
}
// call handlers for `all` event type
handlers = this._handlers.all;
if (handlers instanceof Array) {
// @NOTE: do a concat() here to create a copy of the handlers array,
// so that if another handler is removed of the same type, it doesn't
// interfere with the handlers array
handlers = handlers.concat();
for (i = 0, len = handlers.length; i < len; i++) {
if (handlers[i]) {
handlers[i].call(this, event);
}
}
}
return event;
},
/**
* Removes an event handler from a given event.
* If the handler is not provided, remove all handlers of the given type.
* @param {string} type The name of the event to remove from.
* @param {Function} handler The function to remove as a handler.
* @returns {void}
*/
off: function(type, handler) {
var handlers = this._handlers[type],
i,
len;
if (handlers instanceof Array) {
if (!handler) {
handlers.length = 0;
return;
}
for (i = 0, len = handlers.length; i < len; i++) {
if (handlers[i] === handler || handlers[i].handler === handler) {
handlers.splice(i, 1);
break;
}
}
}
},
/**
* Adds a new event handler that should be removed after it's been triggered once.
* @param {string} type The name of the event to listen for.
* @param {Function} handler The function to call when the event occurs.
* @returns {void}
*/
one: function(type, handler) {
var self = this,
proxy = function (event) {
self.off(type, proxy);
handler.call(self, event);
};
proxy.handler = handler;
this.on(type, proxy);
}
};
})();
/**
* The Crocodoc.Viewer namespace
* @namespace
*/
(function () {
'use strict';
var viewerInstanceCount = 0,
instances = {};
/**
* Crocodoc.Viewer constructor
* @param {jQuery|string|Element} el The element to wrap
* @param {Object} options Configuration options
* @constructor
*/
Crocodoc.Viewer = function (el, options) {
// call the EventTarget constructor to init handlers
Crocodoc.EventTarget.call(this);
var util = Crocodoc.getUtility('common');
var layout,
$el = $(el),
config = util.extend(true, {}, Crocodoc.Viewer.defaults, options),
scope = new Crocodoc.Scope(config),
viewerBase = scope.createComponent('viewer-base');
//Container exists?
if ($el.length === 0) {
throw new Error('Invalid container element');
}
this.id = config.id = ++viewerInstanceCount;
config.api = this;
config.$el = $el;
// register this instance
instances[this.id] = this;
function init() {
viewerBase.init();
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Destroy the viewer instance
* @returns {void}
*/
this.destroy = function () {
// unregister this instance
delete instances[config.id];
// broadcast a destroy message
scope.broadcast('destroy');
// destroy all components and plugins in this scope
scope.destroy();
};
/**
* Intiate loading of document assets
* @returns {void}
*/
this.load = function () {
viewerBase.loadAssets();
};
/**
* Set the layout to the given mode, destroying and cleaning up the current
* layout if there is one
* @param {string} mode The layout mode
* @returns {void}
*/
this.setLayout = function (mode) {
// removing old reference to prevent errors when handling layoutchange message
layout = null;
layout = viewerBase.setLayout(mode);
};
/**
* Zoom to the given value
* @param {float|string} val Numeric zoom level to zoom to or one of:
* Crocodoc.ZOOM_IN
* Crocodoc.ZOOM_OUT
* Crocodoc.ZOOM_AUTO
* Crocodoc.ZOOM_FIT_WIDTH
* Crocodoc.ZOOM_FIT_HEIGHT
* @returns {void}
*/
this.zoom = function (val) {
// adjust for page scale if passed value is a number
var valFloat = parseFloat(val);
if (layout) {
if (valFloat) {
val = valFloat / (config.pageScale || 1);
}
layout.setZoom(val);
}
};
/**
* Scroll to the given page
* @TODO: rename to scrollToPage when possible (and remove this for non-
* page-based viewers)
* @param {int|string} page Page number or one of:
* Crocodoc.SCROLL_PREVIOUS
* Crocodoc.SCROLL_NEXT
* @returns {void}
*/
this.scrollTo = function (page) {
if (layout && util.isFn(layout.scrollTo)) {
layout.scrollTo(page);
}
};
/**
* Scrolls by the given pixel amount from the current location
* @param {int} left Left offset to scroll to
* @param {int} top Top offset to scroll to
* @returns {void}
*/
this.scrollBy = function (left, top) {
if (layout) {
layout.scrollBy(left, top);
}
};
/**
* Focuses the viewport so it can be natively scrolled with the keyboard
* @returns {void}
*/
this.focus = function () {
if (layout) {
layout.focus();
}
};
/**
* Enable text selection, loading text assets per page if necessary
* @returns {void}
*/
this.enableTextSelection = function () {
$el.toggleClass(CSS_CLASS_TEXT_DISABLED, false);
if (!config.enableTextSelection) {
config.enableTextSelection = true;
scope.broadcast('textenabledchange', { enabled: true });
}
};
/**
* Disable text selection, hiding text layer on pages if it's already there
* and disabling the loading of new text assets
* @returns {void}
*/
this.disableTextSelection = function () {
$el.toggleClass(CSS_CLASS_TEXT_DISABLED, true);
if (config.enableTextSelection) {
config.enableTextSelection = false;
scope.broadcast('textenabledchange', { enabled: false });
}
};
/**
* Enable links
* @returns {void}
*/
this.enableLinks = function () {
if (!config.enableLinks) {
$el.removeClass(CSS_CLASS_LINKS_DISABLED);
config.enableLinks = true;
}
};
/**
* Disable links
* @returns {void}
*/
this.disableLinks = function () {
if (config.enableLinks) {
$el.addClass(CSS_CLASS_LINKS_DISABLED);
config.enableLinks = false;
}
};
/**
* Force layout update
* @returns {void}
*/
this.updateLayout = function () {
if (layout) {
layout.update();
}
};
init();
};
Crocodoc.Viewer.prototype = new Crocodoc.EventTarget();
Crocodoc.Viewer.prototype.constructor = Crocodoc.Viewer;
/**
* Get a viewer instance by id
* @param {number} id The id
* @returns {Object} The viewer instance
*/
Crocodoc.Viewer.get = function (id) {
return instances[id];
};
// Global defaults
Crocodoc.Viewer.defaults = {
// the url to load the assets from (required)
url: null,
// document viewer layout
layout: LAYOUT_VERTICAL,
// initial zoom level
zoom: ZOOM_AUTO,
// page to start on
page: 1,
// enable/disable text layer
enableTextSelection: true,
// enable/disable links layer
enableLinks: true,
// enable/disable click-and-drag
enableDragging: false,
// query string parameters to append to all asset requests
queryParams: null,
// plugin configs
plugins: {},
// whether to use the browser window as the viewport into the document (this
// is useful when the document should take up the entire browser window, e.g.,
// on mobile devices)
useWindowAsViewport: false,
//--------------------------------------------------------------------------
// The following are undocumented, internal, or experimental options,
// which are very subject to change and likely to be broken.
// --
// USE AT YOUR OWN RISK!
//--------------------------------------------------------------------------
// whether or not the conversion is finished (eg., pages are ready to be loaded)
conversionIsComplete: true,
// template for loading assets... this should rarely (if ever) change
template: {
svg: 'page-{{page}}.svg',
img: 'page-{{page}}.png',
html: 'text-{{page}}.html',
css: 'stylesheet.css',
json: 'info.json'
},
// default data-providers
dataProviders: {
metadata: 'metadata',
stylesheet: 'stylesheet',
'page-svg': 'page-svg',
'page-text': 'page-text',
'page-img': 'page-img'
},
// page to start/end on (pages outside this range will not be shown)
pageStart: null,
pageEnd: null,
// whether or not to automatically load page one assets immediately (even
// if conversion is not yet complete)
autoloadFirstPage: true,
// zoom levels are relative to the viewport size,
// and the dynamic zoom levels (auto, fitwidth, etc) will be added into the mix
zoomLevels: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0]
};
})();
Crocodoc.addDataProvider('metadata', function(scope) {
'use strict';
var ajax = scope.getUtility('ajax'),
util = scope.getUtility('common'),
config = scope.getConfig();
/**
* Process metadata json and return the result
* @param {string} json The original JSON text
* @returns {string} The processed JSON text
* @private
*/
function processJSONContent(json) {
return util.parseJSON(json);
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
return {
/**
* Retrieve the info.json asset from the server
* @returns {$.Promise} A promise with an additional abort() method that will abort the XHR request.
*/
get: function() {
var url = this.getURL(),
$promise = ajax.fetch(url, Crocodoc.ASSET_REQUEST_RETRIES);
// @NOTE: promise.then() creates a new promise, which does not copy
// custom properties, so we need to create a futher promise and add
// an object with the abort method as the new target
return $promise.then(processJSONContent).promise({
abort: $promise.abort
});
},
/**
* Build and return the URL to the metadata JSON
* @returns {string} The URL
*/
getURL: function () {
var jsonPath = config.template.json;
return config.url + jsonPath + config.queryString;
}
};
});
Crocodoc.addDataProvider('page-img', function(scope) {
'use strict';
var util = scope.getUtility('common'),
config = scope.getConfig();
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
return {
/**
* Retrieve the page image asset from the server
* @param {string} objectType The type of data being requested
* @param {number} pageNum The page number for which to request the page image
* @returns {$.Promise} A promise with an additional abort() method that will abort the img request.
*/
get: function(objectType, pageNum) {
var img = this.getImage(),
retries = Crocodoc.ASSET_REQUEST_RETRIES,
loaded = false,
url = this.getURL(pageNum),
$deferred = $.Deferred();
function loadImage() {
img.setAttribute('src', url);
}
function abortImage() {
img.removeAttribute('src');
}
// add load and error handlers
img.onload = function () {
loaded = true;
$deferred.resolve(img);
};
img.onerror = function () {
if (retries > 0) {
retries--;
abortImage();
loadImage();
} else {
img = null;
loaded = false;
$deferred.reject({
error: 'image failed to load',
resource: url
});
}
};
// load the image
loadImage();
return $deferred.promise({
abort: function () {
if (!loaded) {
abortImage();
$deferred.reject();
}
}
});
},
/**
* Build and return the URL to the PNG asset for the specified page
* @param {number} pageNum The page number
* @returns {string} The URL
*/
getURL: function (pageNum) {
var imgPath = util.template(config.template.img, { page: pageNum });
return config.url + imgPath + config.queryString;
},
/**
* Create and return a new image element (used for testing purporses)
* @returns {Image}
*/
getImage: function () {
return new Image();
}
};
});
Crocodoc.addDataProvider('page-svg', function(scope) {
'use strict';
var MAX_DATA_URLS = 1000;
var util = scope.getUtility('common'),
ajax = scope.getUtility('ajax'),
browser = scope.getUtility('browser'),
subpx = scope.getUtility('subpx'),
config = scope.getConfig(),
destroyed = false,
cache = {};
/**
* Interpolate CSS text into the SVG text
* @param {string} text The SVG text
* @param {string} cssText The CSS text
* @returns {string} The full SVG text
*/
function interpolateCSSText(text, cssText) {
// CSS text
var stylesheetHTML = '';
// If using Firefox with no subpx support, add "text-rendering" CSS.
// @NOTE(plai): We are not adding this to Chrome because Chrome supports "textLength"
// on tspans and because the "text-rendering" property slows Chrome down significantly.
// In Firefox, we're waiting on this bug: https://bugzilla.mozilla.org/show_bug.cgi?id=890692
// @TODO: Use feature detection instead (textLength)
if (browser.firefox && !subpx.isSubpxSupported()) {
stylesheetHTML += '';
}
// inline the CSS!
text = text.replace(/]*>/, stylesheetHTML);
return text;
}
/**
* Process SVG text and return the embeddable result
* @param {string} text The original SVG text
* @returns {string} The processed SVG text
* @private
*/
function processSVGContent(text) {
if (destroyed) {
return;
}
var query = config.queryString.replace('&', '&'),
dataUrlCount;
dataUrlCount = util.countInStr(text, 'xlink:href="data:image');
// remove data:urls from the SVG content if the number exceeds MAX_DATA_URLS
if (dataUrlCount > MAX_DATA_URLS) {
// remove all data:url images that are smaller than 5KB
text = text.replace(/]*>/ig, '');
}
// @TODO: remove this, because we no longer use any external assets in this way
// modify external asset urls for absolute path
text = text.replace(/href="([^"#:]*)"/g, function (match, group) {
return 'href="' + config.url + group + query + '"';
});
return scope.get('stylesheet').then(function (cssText) {
return interpolateCSSText(text, cssText);
});
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
return {
/**
* Retrieve a SVG asset from the server
* @param {string} objectType The type of data being requested
* @param {number} pageNum The page number for which to request the SVG
* @returns {$.Promise} A promise with an additional abort() method that will abort the XHR request.
*/
get: function(objectType, pageNum) {
var url = this.getURL(pageNum),
$promise;
if (cache[pageNum]) {
return cache[pageNum];
}
$promise = ajax.fetch(url, Crocodoc.ASSET_REQUEST_RETRIES);
// @NOTE: promise.then() creates a new promise, which does not copy
// custom properties, so we need to create a futher promise and add
// an object with the abort method as the new target
cache[pageNum] = $promise.then(processSVGContent).promise({
abort: function () {
$promise.abort();
if (cache) {
delete cache[pageNum];
}
}
});
return cache[pageNum];
},
/**
* Build and return the URL to the SVG asset for the specified page
* @param {number} pageNum The page number
* @returns {string} The URL
*/
getURL: function (pageNum) {
var svgPath = util.template(config.template.svg, { page: pageNum });
return config.url + svgPath + config.queryString;
},
/**
* Cleanup the data-provider
* @returns {void}
*/
destroy: function () {
destroyed = true;
util = ajax = subpx = browser = config = cache = null;
}
};
});
Crocodoc.addDataProvider('page-text', function(scope) {
'use strict';
var MAX_TEXT_BOXES = 256;
var util = scope.getUtility('common'),
ajax = scope.getUtility('ajax'),
config = scope.getConfig(),
destroyed = false,
cache = {};
/**
* Process HTML text and return the embeddable result
* @param {string} text The original HTML text
* @returns {string} The processed HTML text
* @private
*/
function processTextContent(text) {
if (destroyed) {
return;
}
// in the text layer, divs are only used for text boxes, so
// they should provide an accurate count
var numTextBoxes = util.countInStr(text, '
MAX_TEXT_BOXES) {
return '';
}
// remove reference to the styles
text = text.replace(/= 200 && status < 300 || status === 304;
}
/**
* Parse AJAX options
* @param {Object} options The options
* @returns {Object} The parsed options
*/
function parseOptions(options) {
options = util.extend(true, {}, options || {});
options.method = options.method || 'GET';
options.headers = options.headers || [];
options.data = options.data || '';
if (typeof options.data !== 'string') {
options.data = $.param(options.data);
if (options.method !== 'GET') {
options.data = options.data;
options.headers.push(['Content-Type', 'application/x-www-form-urlencoded']);
}
}
return options;
}
/**
* Set XHR headers
* @param {XMLHttpRequest} req The request object
* @param {Array} headers Array of headers to set
*/
function setHeaders(req, headers) {
var i;
for (i = 0; i < headers.length; ++i) {
req.setRequestHeader(headers[i][0], headers[i][1]);
}
}
/**
* Make an XHR request
* @param {string} url request URL
* @param {string} method request method
* @param {*} data request data to send
* @param {Array} headers request headers
* @param {Function} success success callback function
* @param {Function} fail fail callback function
* @returns {XMLHttpRequest} Request object
* @private
*/
function doXHR(url, method, data, headers, success, fail) {
var req = support.getXHR();
req.open(method, url, true);
req.onreadystatechange = function () {
var status;
if (req.readyState === 4) { // DONE
// remove the onreadystatechange handler,
// because it could be called again
// @NOTE: we replace it with a noop function, because
// IE8 will throw an error if the value is not of type
// 'function' when using ActiveXObject
req.onreadystatechange = function () {};
try {
status = req.status;
} catch (e) {
// NOTE: IE (9?) throws an error when the request is aborted
fail(req);
return;
}
// status is 0 for successful local file requests, so assume 200
if (status === 0 && isRequestToLocalFile(url)) {
status = 200;
}
if (isSuccessfulStatusCode(status)) {
success(req);
} else {
fail(req);
}
}
};
setHeaders(req, headers);
req.send(data);
return req;
}
/**
* Make an XDR request
* @param {string} url request URL
* @param {string} method request method
* @param {*} data request data to send
* @param {Function} success success callback function
* @param {Function} fail fail callback function
* @returns {XDomainRequest} Request object
* @private
*/
function doXDR(url, method, data, success, fail) {
var req = support.getXDR();
try {
req.open(method, url);
req.onload = function () { success(req); };
// NOTE: IE (8/9) requires onerror, ontimeout, and onprogress
// to be defined when making XDR to https servers
req.onerror = function () { fail(req); };
req.ontimeout = function () { fail(req); };
req.onprogress = function () {};
req.send(data);
} catch (e) {
return fail({
status: 0,
statusText: e.message
});
}
return req;
}
return {
/**
* Make a raw AJAX request
* @param {string} url request URL
* @param {Object} [options] AJAX request options
* @param {string} [options.method] request method, eg. 'GET', 'POST' (defaults to 'GET')
* @param {Array} [options.headers] request headers (defaults to [])
* @param {*} [options.data] request data to send (defaults to null)
* @param {Function} [options.success] success callback function
* @param {Function} [options.fail] fail callback function
* @returns {XMLHttpRequest|XDomainRequest} Request object
*/
request: function (url, options) {
var opt = parseOptions(options),
method = opt.method,
data = opt.data,
headers = opt.headers;
if (method === 'GET' && data) {
url = urlUtil.appendQueryParams(url, data);
data = '';
}
/**
* Function to call on successful AJAX request
* @returns {void}
* @private
*/
function ajaxSuccess(req) {
if (util.isFn(opt.success)) {
opt.success.call(createRequestWrapper(req));
}
return req;
}
/**
* Function to call on failed AJAX request
* @returns {void}
* @private
*/
function ajaxFail(req) {
if (util.isFn(opt.fail)) {
opt.fail.call(createRequestWrapper(req));
}
return req;
}
// is XHR supported at all?
if (!support.isXHRSupported()) {
return opt.fail({
status: 0,
statusText: 'AJAX not supported'
});
}
// cross-domain request? check if CORS is supported...
if (urlUtil.isCrossDomain(url) && !support.isCORSSupported()) {
// the browser supports XHR, but not XHR+CORS, so (try to) use XDR
return doXDR(url, method, data, ajaxSuccess, ajaxFail);
} else {
// the browser supports XHR and XHR+CORS, so just do a regular XHR
return doXHR(url, method, data, headers, ajaxSuccess, ajaxFail);
}
},
/**
* Fetch an asset, retrying if necessary
* @param {string} url A url for the desired asset
* @param {number} retries The number of times to retry if the request fails
* @returns {$.Promise} A promise with an additional abort() method that will abort the XHR request.
*/
fetch: function (url, retries) {
var req,
aborted = false,
ajax = this,
$deferred = $.Deferred();
/**
* If there are retries remaining, make another attempt, otherwise
* give up and reject the deferred
* @param {Object} error The error object
* @returns {void}
* @private
*/
function retryOrFail(error) {
if (retries > 0) {
// if we have retries remaining, make another request
retries--;
req = request();
} else {
// finally give up
$deferred.reject(error);
}
}
/**
* Make an AJAX request for the asset
* @returns {XMLHttpRequest|XDomainRequest} Request object
* @private
*/
function request() {
return ajax.request(url, {
success: function () {
if (!aborted) {
if (this.responseText) {
$deferred.resolve(this.responseText);
} else {
// the response was empty, so consider this a
// failed request
retryOrFail({
error: 'empty response',
status: this.status,
resource: url
});
}
}
},
fail: function () {
if (!aborted) {
retryOrFail({
error: this.statusText,
status: this.status,
resource: url
});
}
}
});
}
req = request();
return $deferred.promise({
abort: function() {
aborted = true;
req.abort();
}
});
}
};
});
Crocodoc.addUtility('browser', function () {
'use strict';
var ua = navigator.userAgent,
version,
browser = {},
ios = /ip(hone|od|ad)/i.test(ua),
android = /android/i.test(ua),
blackberry = /blackberry/i.test(ua),
webos = /webos/i.test(ua),
kindle = /silk|kindle/i.test(ua),
ie = /MSIE|Trident/i.test(ua);
if (ie) {
browser.ie = true;
if (/MSIE/i.test(ua)) {
version = /MSIE\s+(\d+\.\d+)/i.exec(ua);
} else {
version = /Trident.*rv[ :](\d+\.\d+)/.exec(ua);
}
browser.version = version && parseFloat(version[1]);
browser.ielt9 = browser.version < 9;
browser.ielt10 = browser.version < 10;
browser.ielt11 = browser.version < 11;
}
if (ios) {
browser.ios = true;
version = (navigator.appVersion).match(/OS (\d+)_(\d+)_?(\d+)?/);
browser.version = version && parseFloat(version[1] + '.' + version[2]);
}
browser.mobile = /mobile/i.test(ua) || ios || android || blackberry || webos || kindle;
browser.firefox = /firefox/i.test(ua);
if (/safari/i.test(ua)) {
browser.chrome = /chrome/i.test(ua);
browser.safari = !browser.chrome;
}
if (browser.safari) {
version = (navigator.appVersion).match(/Version\/(\d+(\.\d+)?)/);
browser.version = version && parseFloat(version[1]);
}
return browser;
});
/**
* Common utility functions used throughout Crocodoc JS
*/
Crocodoc.addUtility('common', function () {
'use strict';
var DEFAULT_PT2PX_RATIO = 1.33333;
var util = {};
util.extend = $.extend;
util.each = $.each;
util.map = $.map;
util.param = $.param;
util.parseJSON = $.parseJSON;
util.stringifyJSON = typeof window.JSON !== 'undefined' ?
window.JSON.stringify : // IE 8+
function () {
throw new Error('JSON.stringify not supported');
};
return $.extend(util, {
/**
* Left bistect of list, optionally of property of objects in list
* @param {Array} list List of items to bisect
* @param {number} x The number to bisect against
* @param {string} [prop] Optional property to check on list items instead of using the item itself
* @returns {int} The index of the bisection
*/
bisectLeft: function (list, x, prop) {
var val, mid, low = 0, high = list.length;
while (low < high) {
mid = Math.floor((low + high) / 2);
val = prop ? list[mid][prop] : list[mid];
if (val < x) {
low = mid + 1;
} else {
high = mid;
}
}
return low;
},
/**
* Right bistect of list, optionally of property of objects in list
* @param {Array} list List of items to bisect
* @param {number} x The number to bisect against
* @param {string} [prop] Optional property to check on list items instead of using the item itself
* @returns {int} The index of the bisection
*/
bisectRight: function (list, x, prop) {
var val, mid, low = 0, high = list.length;
while (low < high) {
mid = Math.floor((low + high) / 2);
val = prop ? list[mid][prop] : list[mid];
if (x < val) {
high = mid;
} else {
low = mid + 1;
}
}
return low;
},
/**
* Clamp x to range [a,b]
* @param {number} x The value to clamp
* @param {number} a Low value
* @param {number} b High value
* @returns {number} The clamped value
*/
clamp: function (x, a, b) {
if (x < a) {
return a;
} else if (x > b) {
return b;
}
return x;
},
/**
* Returns the sign of the given number
* @param {number} value The number
* @returns {number} The sign (-1 or 1), or 0 if value === 0
*/
sign: function(value) {
var number = parseInt(value, 10);
if (!number) {
return number;
}
return number < 0 ? -1 : 1;
},
/**
* Returns true if the given value is a function
* @param {*} val Any value
* @returns {Boolean} true if val is a function, false otherwise
*/
isFn: function (val) {
return typeof val === 'function';
},
/**
* Search for a specified value within an array, and return its index (or -1 if not found)
* @param {*} value The value to search for
* @param {Array} array The array to search
* @returns {int} The index of value in array or -1 if not found
*/
inArray: function (value, array) {
if (util.isFn(array.indexOf)) {
return array.indexOf(value);
} else {
return $.inArray(value, array);
}
},
/**
* Constrains the range [low,high] to the range [0,max]
* @param {number} low The low value
* @param {number} high The high value
* @param {number} max The max value (0 is implicit min)
* @returns {Object} The range object containing min and max values
*/
constrainRange: function (low, high, max) {
var length = high - low;
if (length < 0) {
return {
min: -1,
max: -1
};
}
low = util.clamp(low, 0, max);
high = util.clamp(low + length, 0, max);
if (high - low < length) {
low = util.clamp(high - length, 0, max);
}
return {
min: low,
max: high
};
},
/**
* Return the current time since epoch in ms
* @returns {int} The current time
*/
now: function () {
return (new Date()).getTime();
},
/**
* Creates and returns a new, throttled version of the passed function,
* that, when invoked repeatedly, will only actually call the original
* function at most once per every wait milliseconds
* @param {int} wait Time to wait between calls in ms
* @param {Function} fn The function to throttle
* @returns {Function} The throttled function
*/
throttle: function (wait, fn) {
var context,
args,
timeout,
result,
previous = 0;
function later () {
previous = util.now();
timeout = null;
result = fn.apply(context, args);
}
return function throttled() {
var now = util.now(),
remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0) {
clearTimeout(timeout);
timeout = null;
previous = now;
result = fn.apply(context, args);
} else if (!timeout) {
timeout = setTimeout(later, remaining);
}
return result;
};
},
/**
* Creates and returns a new debounced version of the passed function
* which will postpone its execution until after wait milliseconds
* have elapsed since the last time it was invoked.
* @param {int} wait Time to wait between calls in ms
* @param {Function} fn The function to debounced
* @returns {Function} The debounced function
*/
debounce: function (wait, fn) {
var context,
args,
timeout,
timestamp,
result;
function later() {
var last = util.now() - timestamp;
if (last < wait) {
timeout = setTimeout(later, wait - last);
} else {
timeout = null;
result = fn.apply(context, args);
context = args = null;
}
}
return function debounced() {
context = this;
args = arguments;
timestamp = util.now();
if (!timeout) {
timeout = setTimeout(later, wait);
}
return result;
};
},
/**
* Insert the given CSS string into the DOM and return the resulting DOMElement
* @param {string} css The CSS string to insert
* @returns {Element} The