/*! Crocodoc Viewer - v0.10.8 | (c) 2015 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 = '
' + '
' + '
' + ''; var PAGE_HTML_TEMPLATE = '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '
' + '
' + '
'; // 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() { 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 = /]*>(\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 = ''; // 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(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(/]*>/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 () { var retryAfter, req; if (!aborted) { req = this.rawRequest; // check status code for 202 if (this.status === 202 && util.isFn(req.getResponseHeader)) { // retry the request retryAfter = parseInt(req.getResponseHeader('retry-after')); if (retryAfter > 0) { setTimeout(request, retryAfter * 1000); return; } } 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