UNPKG

viewer

Version:

A viewer for documents converted with the Box View API

1,426 lines (1,231 loc) 230 kB
/*! 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('&', '&#38;'), 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 //------------------------------------------------