viewer
Version:
A viewer for documents converted with the Box View API
1,426 lines (1,231 loc) • 230 kB
JavaScript
/*! Crocodoc Viewer - v0.10.11 | (c) 2016 Box */
(function (window) {
/*global jQuery*/
/*jshint unused:false, undef:false*/
'use strict';
window.Crocodoc = (function(fn) {
if (typeof exports === 'object') {
// nodejs / browserify - export a function that accepts a jquery impl
module.exports = fn;
} else {
// normal browser environment
return fn(jQuery);
}
}(function($) {
var CSS_CLASS_PREFIX = 'crocodoc-',
ATTR_SVG_VERSION = 'data-svg-version',
CSS_CLASS_VIEWER = CSS_CLASS_PREFIX + 'viewer',
CSS_CLASS_DOC = CSS_CLASS_PREFIX + 'doc',
CSS_CLASS_VIEWPORT = CSS_CLASS_PREFIX + 'viewport',
CSS_CLASS_LOGO = CSS_CLASS_PREFIX + 'viewer-logo',
CSS_CLASS_DRAGGABLE = CSS_CLASS_PREFIX + 'draggable',
CSS_CLASS_DRAGGING = CSS_CLASS_PREFIX + 'dragging',
CSS_CLASS_TEXT_SELECTED = CSS_CLASS_PREFIX + 'text-selected',
CSS_CLASS_TEXT_DISABLED = CSS_CLASS_PREFIX + 'text-disabled',
CSS_CLASS_LINKS_DISABLED = CSS_CLASS_PREFIX + 'links-disabled',
CSS_CLASS_MOBILE = CSS_CLASS_PREFIX + 'mobile',
CSS_CLASS_IELT9 = CSS_CLASS_PREFIX + 'ielt9',
CSS_CLASS_SUPPORTS_SVG = CSS_CLASS_PREFIX + 'supports-svg',
CSS_CLASS_WINDOW_AS_VIEWPORT = CSS_CLASS_PREFIX + 'window-as-viewport',
CSS_CLASS_LAYOUT_PREFIX = CSS_CLASS_PREFIX + 'layout-',
CSS_CLASS_CURRENT_PAGE = CSS_CLASS_PREFIX + 'current-page',
CSS_CLASS_PRECEDING_PAGE = CSS_CLASS_PREFIX + 'preceding-page',
CSS_CLASS_PAGE = CSS_CLASS_PREFIX + 'page',
CSS_CLASS_PAGE_INNER = CSS_CLASS_PAGE + '-inner',
CSS_CLASS_PAGE_CONTENT = CSS_CLASS_PAGE + '-content',
CSS_CLASS_PAGE_SVG = CSS_CLASS_PAGE + '-svg',
CSS_CLASS_PAGE_TEXT = CSS_CLASS_PAGE + '-text',
CSS_CLASS_PAGE_LINK = CSS_CLASS_PAGE + '-link',
CSS_CLASS_PAGE_LINKS = CSS_CLASS_PAGE + '-links',
CSS_CLASS_PAGE_AUTOSCALE = CSS_CLASS_PAGE + '-autoscale',
CSS_CLASS_PAGE_LOADING = CSS_CLASS_PAGE + '-loading',
CSS_CLASS_PAGE_ERROR = CSS_CLASS_PAGE + '-error',
CSS_CLASS_PAGE_VISIBLE = CSS_CLASS_PAGE + '-visible',
CSS_CLASS_PAGE_AUTOSCALE = CSS_CLASS_PAGE + '-autoscale',
CSS_CLASS_PAGE_PREV = CSS_CLASS_PAGE + '-prev',
CSS_CLASS_PAGE_NEXT = CSS_CLASS_PAGE + '-next',
CSS_CLASS_PAGE_BEFORE = CSS_CLASS_PAGE + '-before',
CSS_CLASS_PAGE_AFTER = CSS_CLASS_PAGE + '-after',
CSS_CLASS_PAGE_BEFORE_BUFFER = CSS_CLASS_PAGE + '-before-buffer',
CSS_CLASS_PAGE_AFTER_BUFFER = CSS_CLASS_PAGE + '-after-buffer',
PRESENTATION_CSS_CLASSES = [
CSS_CLASS_PAGE_NEXT,
CSS_CLASS_PAGE_AFTER,
CSS_CLASS_PAGE_PREV,
CSS_CLASS_PAGE_BEFORE,
CSS_CLASS_PAGE_BEFORE_BUFFER,
CSS_CLASS_PAGE_AFTER_BUFFER
].join(' ');
var VIEWER_HTML_TEMPLATE =
'<div tabindex="-1" class="' + CSS_CLASS_VIEWPORT + '">' +
'<div class="' + CSS_CLASS_DOC + '"></div>' +
'</div>' +
'<div class="' + CSS_CLASS_LOGO + '"></div>';
var PAGE_HTML_TEMPLATE =
'<div class="' + CSS_CLASS_PAGE + ' ' + CSS_CLASS_PAGE_LOADING + '" ' +
'style="width:{{w}}px; height:{{h}}px;" data-width="{{w}}" data-height="{{h}}">' +
'<div class="' + CSS_CLASS_PAGE_INNER + '">' +
'<div class="' + CSS_CLASS_PAGE_CONTENT + '">' +
'<div class="' + CSS_CLASS_PAGE_SVG + '"></div>' +
'<div class="' + CSS_CLASS_PAGE_AUTOSCALE + '">' +
'<div class="' + CSS_CLASS_PAGE_TEXT + '"></div>' +
'<div class="' + CSS_CLASS_PAGE_LINKS + '"></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
// 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 = '<style>html,body{width:100%;height:100%;margin:0;overflow:hidden;}</style>',
SVG_CONTAINER_TEMPLATE = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg"><script><![CDATA[('+PROXY_SVG+')()]]></script></svg>',
// 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() {
if (img) {
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 = {},
// NOTE: there are cases where the stylesheet link tag will be self-
// closing, so check for both cases
inlineCSSRegExp = /<xhtml:link[^>]*>(\s*<\/xhtml:link>)?/i;
/**
* 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 = '<style>' + cssText + '</style>';
// 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 += '<style>text { text-rendering: geometricPrecision; }</style>';
}
// inline the CSS!
text = text.replace(inlineCSSRegExp, 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(/<image[\s\w-_="]*xlink:href="data:image\/[^"]{0,5120}"[^>]*>/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, '<div');
// too many textboxes... don't load this page for performance reasons
if (numTextBoxes > MAX_TEXT_BOXES) {
return '';
}
// remove reference to the styles
text = text.replace(/<link rel="stylesheet".*/, '');
return text;
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
return {
/**
* Retrieve a text asset from the server
* @param {string} objectType The type of data being requested
* @param {number} pageNum The page number for which to request the text HTML
* @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(processTextContent).promise({
abort: function () {
$promise.abort();
if (cache) {
delete cache[pageNum];
}
}
});
return cache[pageNum];
},
/**
* Build and return the URL to the HTML asset for the specified page
* @param {number} pageNum The page number
* @returns {string} The URL
*/
getURL: function (pageNum) {
var textPath = util.template(config.template.html, { page: pageNum });
return config.url + textPath + config.queryString;
},
/**
* Cleanup the data-provider
* @returns {void}
*/
destroy: function () {
destroyed = true;
util = ajax = config = cache = null;
}
};
});
Crocodoc.addDataProvider('stylesheet', function(scope) {
'use strict';
var ajax = scope.getUtility('ajax'),
browser = scope.getUtility('browser'),
config = scope.getConfig(),
$cachedPromise;
/**
* Process stylesheet text and return the embeddable result
* @param {string} text The original CSS text
* @returns {string} The processed CSS text
* @private
*/
function processStylesheetContent(text) {
// @NOTE: There is a bug in IE that causes the text layer to
// not render the font when loaded for a second time (i.e.,
// destroy and recreate a viewer for the same document), so
// namespace the font-family so there is no collision
if (browser.ie) {
text = text.replace(/font-family:[\s\"\']*([\w-]+)\b/g,
'$0-' + config.id);
}
return text;
}
//--------------------------------------------------------------------------
// Public
//------------------------------------------------