From 8a33a55feefbd8b8408d5f410a1a8aa0dac11a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lucas?= Date: Sun, 8 Sep 2013 18:34:15 +0200 Subject: [PATCH] Add monocle --HG-- extra : rebase_source : 9503ba50aa6dbba68dce8dbb4898cefcba1293f8 --- resources/monocle/scripts/monocore.js | 5641 +++++++++++++++++++++++++ resources/monocle/scripts/monoctrl.js | 985 +++++ resources/monocle/styles/monocore.css | 195 + resources/monocle/styles/monoctrl.css | 169 + 4 files changed, 6990 insertions(+) create mode 100644 resources/monocle/scripts/monocore.js create mode 100644 resources/monocle/scripts/monoctrl.js create mode 100644 resources/monocle/styles/monocore.css create mode 100644 resources/monocle/styles/monoctrl.css diff --git a/resources/monocle/scripts/monocore.js b/resources/monocle/scripts/monocore.js new file mode 100644 index 0000000..ae8b645 --- /dev/null +++ b/resources/monocle/scripts/monocore.js @@ -0,0 +1,5641 @@ +/*! + * Monocle - A silky, tactile browser-based ebook JavaScript library. + * + * Copyright 2012, Joseph Pearson + * Licensed under the MIT license. + */ + + +Monocle = { + VERSION: "3.2.0" +}; + + +Monocle.Dimensions = {}; +Monocle.Controls = {}; +Monocle.Flippers = {}; +Monocle.Panels = {}; +// A class that tests the browser environment for required capabilities and +// known bugs (for which we have workarounds). +// +Monocle.Env = function () { + + var API = { constructor: Monocle.Env } + var k = API.constants = API.constructor; + var p = API.properties = { + // Assign to a function before running survey in order to get + // results as they come in. The function should take two arguments: + // testName and value. + resultCallback: null + } + + // These are private variables so they don't clutter up properties. + var css = Monocle.Browser.css; + var activeTestName = null; + var frameLoadCallback = null; + var testFrame = null; + var testFrameCntr = null; + var testFrameSize = 100; + var surveyCallback = null; + + + function survey(cb) { + surveyCallback = cb; + runNextTest(); + } + + + function runNextTest() { + var test = envTests.shift(); + if (!test) { return completed(); } + activeTestName = test[0]; + try { test[1](); } catch (e) { result(e); } + } + + + // Each test should call this to say "I'm finished, run the next test." + // + function result(val) { + API[activeTestName] = val; + if (p.resultCallback) { p.resultCallback(activeTestName, val); } + runNextTest(); + return val; + } + + + // Invoked after all tests have run. + // + function completed() { + // Remove the test frame after a slight delay (otherwise Gecko spins). + Monocle.defer(removeTestFrame); + + if (typeof surveyCallback == "function") { + fn = surveyCallback; + surveyCallback = null; + fn(API); + } + } + + + // A bit of sugar for simplifying a detection pattern: does this + // function exist? + // + // Pass a string snippet of JavaScript to be evaluated. + // + function testForFunction(str) { + return function () { result(typeof eval(str) == "function"); } + } + + + // A bit of sugar to indicate that the detection function for this test + // hasn't been written yet... + // + // Pass the value you want assigned for the test until it is implemented. + // + function testNotYetImplemented(rslt) { + return function () { result(rslt); } + } + + + // Loads (or reloads) a hidden iframe so that we can test browser features. + // + // cb is the callback that is fired when the test frame's content is loaded. + // + // src is optional, in which case it defaults to 4. If provided, it can be + // a number (specifying the number of pages of default content), or a string, + // which will be loaded as a URL. + // + function loadTestFrame(cb, src) { + if (!testFrame) { testFrame = createTestFrame(); } + frameLoadCallback = cb; + + src = src || 4; + + if (typeof src == "number") { + var pgs = []; + for (var i = 1, ii = src; i <= ii; ++i) { + pgs.push("
Page "+i+"
"); + } + var divStyle = [ + "display:inline-block", + "line-height:"+testFrameSize+"px", + "width:"+testFrameSize+"px" + ].join(";"); + src = "javascript:'"+ + ''+ + ''+ + ''+pgs.join("")+''+ + "'"; + } + + testFrame.src = src; + } + + + // Creates the hidden test frame and returns it. + // + function createTestFrame() { + testFrameCntr = document.createElement('div'); + testFrameCntr.style.cssText = [ + "width:"+testFrameSize+"px", + "height:"+testFrameSize+"px", + "overflow:hidden", + "position:absolute", + "visibility:hidden" + ].join(";"); + document.body.appendChild(testFrameCntr); + + var fr = document.createElement('iframe'); + testFrameCntr.appendChild(fr); + fr.setAttribute("scrolling", "no"); + fr.style.cssText = [ + "width:100%", + "height:100%", + "border:none", + "background:#900" + ].join(";"); + fr.addEventListener( + "load", + function () { + if (!fr.contentDocument || !fr.contentDocument.body) { return; } + var bd = fr.contentDocument.body; + bd.style.cssText = ([ + "margin:0", + "padding:0", + "position:absolute", + "height:100%", + "width:100%", + "-webkit-column-width:"+testFrameSize+"px", + "-webkit-column-gap:0", + "-webkit-column-fill:auto", + "-moz-column-width:"+testFrameSize+"px", + "-moz-column-gap:0", + "-moz-column-fill:auto", + "-o-column-width:"+testFrameSize+"px", + "-o-column-gap:0", + "-o-column-fill:auto", + "column-width:"+testFrameSize+"px", + "column-gap:0", + "column-fill:auto" + ].join(";")); + if (bd.scrollHeight > testFrameSize) { + bd.style.cssText += ["min-width:200%", "overflow:hidden"].join(";"); + if (bd.scrollHeight <= testFrameSize) { + bd.className = "column-force"; + } else { + bd.className = "column-failed "+bd.scrollHeight; + } + } + frameLoadCallback(fr); + }, + false + ); + return fr; + } + + + function removeTestFrame() { + if (testFrameCntr && testFrameCntr.parentNode) { + testFrameCntr.parentNode.removeChild(testFrameCntr); + } + } + + + function columnedWidth(fr) { + var bd = fr.contentDocument.body; + var de = fr.contentDocument.documentElement; + return Math.max(bd.scrollWidth, de.scrollWidth); + } + + + var envTests = [ + + // TEST FOR REQUIRED CAPABILITIES + + ["supportsW3CEvents", testForFunction("window.addEventListener")], + ["supportsCustomEvents", testForFunction("document.createEvent")], + ["supportsColumns", function () { + result(css.supportsPropertyWithAnyPrefix('column-width')); + }], + ["supportsTransform", function () { + result(css.supportsProperty([ + 'transformProperty', + 'WebkitTransform', + 'MozTransform', + 'OTransform', + 'msTransform' + ])); + }], + + + // TEST FOR OPTIONAL CAPABILITIES + + // Does it do CSS transitions? + ["supportsTransition", function () { + result(css.supportsPropertyWithAnyPrefix('transition')) + }], + + // Can we find nodes in a document with an XPath? + // + ["supportsXPath", testForFunction("document.evaluate")], + + // Can we find nodes in a document natively with a CSS selector? + // + ["supportsQuerySelector", testForFunction("document.querySelector")], + + // Can we do 3d transforms? + // + ["supportsTransform3d", function () { + result( + css.supportsMediaQueryProperty('transform-3d') && + css.supportsProperty([ + 'perspectiveProperty', + 'WebkitPerspective', + 'MozPerspective', + 'OPerspective', + 'msPerspective' + ]) && + !Monocle.Browser.renders.slow // Some older browsers can't be trusted. + ); + }], + + + // Commonly-used browser functionality + ["supportsOfflineCache", function () { + result(typeof window.applicationCache != 'undefined'); + }], + + ["supportsLocalStorage", function () { + // NB: Some duplicitous early Android browsers claim to have + // localStorage, but calls to getItem() fail. + result( + typeof window.localStorage != "undefined" && + typeof window.localStorage.getItem == "function" + ) + }], + + + // CHECK OUT OUR CONTEXT + + // Does the device have a MobileSafari-style touch interface? + // + ["touch", function () { + result( + ('ontouchstart' in window) || + css.supportsMediaQueryProperty('touch-enabled') + ); + }], + + // Is the Reader embedded, or in the top-level window? + // + ["embedded", function () { result(top != self) }], + + + // TEST FOR CERTAIN RENDERING OR INTERACTION BUGS + + // iOS (at least up to version 4.1) makes a complete hash of touch events + // when an iframe is overlapped by other elements. It's a dog's breakfast. + // See test/bugs/ios-frame-touch-bug for details. + // + ["brokenIframeTouchModel", function () { + result(Monocle.Browser.iOSVersionBelow("4.2")); + }], + + // Webkit-based browsers put floated elements in the wrong spot when + // columns are used -- they appear way down where they would be if there + // were no columns. Presumably the float positions are calculated before + // the columns. A bug has been lodged, and it's fixed in recent WebKits. + // + ["floatsIgnoreColumns", function () { + if (!Monocle.Browser.is.WebKit) { return result(false); } + match = navigator.userAgent.match(/AppleWebKit\/([\d\.]+)/); + if (!match) { return result(false); } + return result(match[1] < "534.30"); + }], + + // The latest engines all agree that if a body is translated leftwards, + // its scrollWidth is shortened. But some older WebKits (notably iOS4) + // do not subtract translateX values from scrollWidth. In this case, + // we should not add the translate back when calculating the width. + // + ["widthsIgnoreTranslate", function () { + loadTestFrame(function (fr) { + var firstWidth = columnedWidth(fr); + var s = fr.contentDocument.body.style; + var props = css.toDOMProps("transform"); + for (var i = 0, ii = props.length; i < ii; ++i) { + s[props[i]] = "translateX(-600px)"; + } + var secondWidth = columnedWidth(fr); + for (i = 0, ii = props.length; i < ii; ++i) { + s[props[i]] = "none"; + } + result(secondWidth == firstWidth); + }); + }], + + // On Android browsers, if the component iframe has a relative width (ie, + // 100%), the width of the entire browser will keep expanding and expanding + // to fit the width of the body of the iframe (which, with columns, is + // massive). So, 100% is treated as "of the body content" rather than "of + // the parent dimensions". In this scenario, we need to give the component + // iframe a fixed width in pixels. + // + // In iOS, the frame is clipped by overflow:hidden, so this doesn't seem to + // be a problem. + // + ["relativeIframeExpands", function () { + result(navigator.userAgent.indexOf("Android 2") >= 0); + }], + + // iOS3 will pause JavaScript execution if it gets a style-change + a + // scroll change on a component body. Weirdly, this seems to break GBCR + // in iOS4. + // + ["scrollToApplyStyle", function () { + result(Monocle.Browser.iOSVersionBelow("4")); + }], + + + // TEST FOR OTHER QUIRKY BROWSER BEHAVIOUR + + // Older versions of WebKit (iOS3, Kindle3) need a min-width set on the + // body of the iframe at 200%. This forces columns. But when this + // min-width is set, it's more difficult to recognise 1 page components, + // so we generally don't want to force it unless we have to. + // + ["forceColumns", function () { + loadTestFrame(function (fr) { + var bd = fr.contentDocument.body; + result(bd.className ? true : false); + }); + }], + + // A component iframe's body is absolutely positioned. This means that + // the documentElement should have a height of 0, since it contains nothing + // other than an absolutely positioned element. + // + // But for some browsers (Gecko and Opera), the documentElement is as + // wide as the full columned content, and the body is only as wide as + // the iframe element (ie, the first column). + // + // It gets weirder. Gecko sometimes behaves like WebKit (not clipping the + // body) IF the component has been loaded via HTML/JS/Nodes, not URL. Still + // can't reproduce outside Monocle. + // + // FIXME: If we can figure out a reliable behaviour for Gecko, we should + // use it to precalculate the workaround. At the moment, this test isn't + // used, but it belongs in src/dimensions/columns.js#columnedDimensions(). + // + // ["iframeBodyWidthClipped", function () { + // loadTestFrame(function (fr) { + // var doc = fr.contentDocument; + // result( + // doc.body.scrollWidth <= testFrameSize && + // doc.documentElement.scrollWidth > testFrameSize + // ); + // }) + // }], + + // Finding the page that a given HTML node is on is typically done by + // calculating the offset of its rectange from the body's rectangle. + // + // But if this information isn't provided by the browser, we need to use + // node.scrollIntoView and check the scrollOffset. Basically iOS3 is the + // only target platform that doesn't give us the rectangle info. + // + ["findNodesByScrolling", function () { + result(typeof document.body.getBoundingClientRect !== "function"); + }], + + // In MobileSafari browsers, iframes are rendered at the width and height + // of their content, rather than having scrollbars. So in that case, it's + // the containing element (the "sheaf") that must be scrolled, not the + // iframe body. + // + ["sheafIsScroller", function () { + loadTestFrame(function (fr) { + result(fr.parentNode.scrollWidth > testFrameSize); + }); + }], + + // For some reason, iOS MobileSafari sometimes loses track of a page after + // slideOut -- it thinks it has an x-translation of 0, rather than -768 or + // whatever. So the page gets "stuck" there, until it is given a non-zero + // x-translation. The workaround is to set a non-zero duration on the jumpIn, + // which seems to force WebKit to recalculate the x of the page. Weird, yeah. + // + ["stickySlideOut", function () { + result(Monocle.Browser.is.MobileSafari); + }], + + + // Chrome and Firefox incorrectly clip text when the dimensions of + // a page are not an integer. IE10 clips text when the page dimensions + // are rounded. + // + ['roundPageDimensions', function () { + result(!Monocle.Browser.is.IE); + }], + + + + // In IE10, the element of the iframe's document has scrollbars, + // unless you set its style.overflow to 'hidden'. + // + ['documentElementHasScrollbars', function () { + result(Monocle.Browser.is.IE); + }], + + + // Older versions of iOS (<6) would render blank pages if they were + // off the screen when their layout/position was updated. + // + ['offscreenRenderingClipped', function () { + result(Monocle.Browser.iOSVersionBelow('6')); + }], + + + // Gecko is better at loading content with document.write than with + // javascript: URLs. With the latter, it tends to put cruft in history, + // and gets confused by . + ['loadHTMLWithDocWrite', function () { + result(Monocle.Browser.is.Gecko || Monocle.Browser.is.Opera); + }] + + ]; + + + function isCompatible() { + return ( + API.supportsW3CEvents && + API.supportsCustomEvents && + API.supportsColumns && + API.supportsTransform && + !API.brokenIframeTouchModel + ); + } + + + API.survey = survey; + API.isCompatible = isCompatible; + + return API; +} +; +// A class for manipulating CSS properties in a browser-engine-aware way. +// +Monocle.CSS = function () { + + var API = { constructor: Monocle.CSS } + var k = API.constants = API.constructor; + var p = API.properties = { + guineapig: document.createElement('div') + } + + + // Returns engine-specific properties, + // + // eg: + // + // toCSSProps('transform') + // + // ... in WebKit, this will return: + // + // ['transform', '-webkit-transform'] + // + function toCSSProps(prop) { + var props = [prop]; + var eng = k.engines.indexOf(Monocle.Browser.engine); + if (eng) { + var pf = k.prefixes[eng]; + if (pf) { + props.push(pf+prop); + } + } + return props; + } + + + // Returns an engine-specific CSS string. + // + // eg: + // + // toCSSDeclaration('column-width', '300px') + // + // ... in Mozilla, this will return: + // + // "column-width: 300px; -moz-column-width: 300px;" + // + function toCSSDeclaration(prop, val) { + var props = toCSSProps(prop); + for (var i = 0, ii = props.length; i < ii; ++i) { + props[i] += ": "+val+";"; + } + return props.join(""); + } + + + // Returns an array of DOM properties specific to this engine. + // + // eg: + // + // toDOMProps('column-width') + // + // ... in Opera, this will return: + // + // [columnWidth, OColumnWidth] + // + function toDOMProps(prop) { + var parts = prop.split('-'); + for (var i = parts.length; i > 0; --i) { + parts[i] = capStr(parts[i]); + } + + var props = [parts.join('')]; + var eng = k.engines.indexOf(Monocle.Browser.engine); + if (eng) { + var pf = k.domprefixes[eng]; + if (pf) { + parts[0] = capStr(parts[0]); + props.push(pf+parts.join('')); + } + } + return props; + } + + + // Is this exact property (or any in this array of properties) supported + // by this engine? + // + function supportsProperty(props) { + for (var i in props) { + if (p.guineapig.style[props[i]] !== undefined) { return true; } + } + return false; + } // Thanks modernizr! + + + + // Is this property (or a prefixed variant) supported by this engine? + // + function supportsPropertyWithAnyPrefix(prop) { + return supportsProperty(toDOMProps(prop)); + } + + + function supportsMediaQuery(query) { + var gpid = "monocle_guineapig"; + p.guineapig.id = gpid; + var st = document.createElement('style'); + st.textContent = query+'{#'+gpid+'{height:3px}}'; + (document.head || document.getElementsByTagName('head')[0]).appendChild(st); + document.documentElement.appendChild(p.guineapig); + + var result = p.guineapig.offsetHeight === 3; + + st.parentNode.removeChild(st); + p.guineapig.parentNode.removeChild(p.guineapig); + + return result; + } // Thanks modernizr! + + + function supportsMediaQueryProperty(prop) { + return supportsMediaQuery( + '@media (' + k.prefixes.join(prop+'),(') + 'monocle__)' + ); + } + + + function capStr(wd) { + return wd ? wd.charAt(0).toUpperCase() + wd.substr(1) : ""; + } + + + API.toCSSProps = toCSSProps; + API.toCSSDeclaration = toCSSDeclaration; + API.toDOMProps = toDOMProps; + API.supportsProperty = supportsProperty; + API.supportsPropertyWithAnyPrefix = supportsPropertyWithAnyPrefix; + API.supportsMediaQuery = supportsMediaQuery; + API.supportsMediaQueryProperty = supportsMediaQueryProperty; + + return API; +} + + +Monocle.CSS.engines = ["W3C", "WebKit", "Gecko", "Opera", "IE", "Konqueror"]; +Monocle.CSS.prefixes = ["", "-webkit-", "-moz-", "-o-", "-ms-", "-khtml-"]; +Monocle.CSS.domprefixes = ["", "Webkit", "Moz", "O", "ms", "Khtml"]; +// STUBS - simple debug functions and polyfills to normalise client +// execution environments. + + +// A little console stub if not initialized in a console-equipped browser. +// +if (typeof window.console == "undefined") { + window.console = { messages: [] } + window.console.log = function (msg) { + this.messages.push(msg); + } + window.console.warn = window.console.log; +} + + +// A simple version of console.dir that works on iOS. +// +window.console.compatDir = function (obj) { + var stringify = function (o) { + var parts = []; + for (x in o) { + parts.push(x + ": " + o[x]); + } + return parts.join(";\n"); + } + + var out = stringify(obj); + window.console.log(out); + return out; +} + + +// This is called by Monocle methods and practices that are no longer +// recommended and will soon be removed. +// +window.console.deprecation = function (msg) { + console.warn("[DEPRECATION]: "+msg); + if (typeof console.trace == "function") { + console.trace(); + } +} + + +// A convenient alias for setTimeout that assumes 0 if no timeout is specified. +// +Monocle.defer = function (fn, time) { + if (typeof fn == "function") { + return setTimeout(fn, time || 0); + } +} +; +Monocle.Browser = {}; + +// Compare the user-agent string to a string or regex pattern. +// +Monocle.Browser.uaMatch = function (test) { + var ua = navigator.userAgent; + if (typeof test == "string") { return ua.indexOf(test) >= 0; } + return !!ua.match(test); +} + + +// Detect the browser engine and set boolean flags for reference. +// +Monocle.Browser.is = { + IE: !!(window.attachEvent && !Monocle.Browser.uaMatch('Opera')), + Opera: Monocle.Browser.uaMatch('Opera'), + WebKit: Monocle.Browser.uaMatch(/Apple\s?WebKit/), + Gecko: Monocle.Browser.uaMatch('Gecko') && !Monocle.Browser.uaMatch('KHTML'), + MobileSafari: Monocle.Browser.uaMatch(/OS \d_.*AppleWebKit.*Mobile/) +} + + +// Set the browser engine string. +// +if (Monocle.Browser.is.IE) { + Monocle.Browser.engine = "IE"; +} else if (Monocle.Browser.is.Opera) { + Monocle.Browser.engine = "Opera"; +} else if (Monocle.Browser.is.WebKit) { + Monocle.Browser.engine = "WebKit"; +} else if (Monocle.Browser.is.Gecko) { + Monocle.Browser.engine = "Gecko"; +} else { + console.warn("Unknown engine; assuming W3C compliant."); + Monocle.Browser.engine = "W3C"; +} + + +// Detect the client platform (typically device/operating system). +// +Monocle.Browser.on = { + iPhone: Monocle.Browser.is.MobileSafari && screen.width == 320, + iPad: Monocle.Browser.is.MobileSafari && screen.width == 768, + UIWebView: ( + Monocle.Browser.is.MobileSafari && + !Monocle.Browser.uaMatch('Safari') && + !navigator.standalone + ), + BlackBerry: Monocle.Browser.uaMatch('BlackBerry'), + Android: ( + Monocle.Browser.uaMatch('Android') || + Monocle.Browser.uaMatch(/Linux;.*EBRD/) // Sony Readers + ), + MacOSX: ( + Monocle.Browser.uaMatch('Mac OS X') && + !Monocle.Browser.is.MobileSafari + ), + Kindle3: Monocle.Browser.uaMatch(/Kindle\/3/) +} + + +// It is only because MobileSafari is responsible for so much anguish that +// we special-case it here. Not a badge of honour. +// +if (Monocle.Browser.is.MobileSafari) { + (function () { + var ver = navigator.userAgent.match(/ OS ([\d_]+)/); + if (ver) { + Monocle.Browser.iOSVersion = ver[1].replace(/_/g, '.'); + } else { + console.warn("Unknown MobileSafari user agent: "+navigator.userAgent); + } + })(); +} +Monocle.Browser.iOSVersionBelow = function (strOrNum) { + return !!Monocle.Browser.iOSVersion && Monocle.Browser.iOSVersion < strOrNum; +} + + +// Some browser environments are too slow or too problematic for +// special animation effects. +// +// FIXME: These tests are too opinionated. Replace with more targeted tests. +// +Monocle.Browser.renders = (function () { + var ua = navigator.userAgent; + var caps = {}; + caps.eInk = Monocle.Browser.on.Kindle3; + caps.slow = ( + caps.eInk || + (Monocle.Browser.on.Android && !ua.match(/Chrome/)) || + Monocle.Browser.on.Blackberry || + ua.match(/NintendoBrowser/) + ); + return caps; +})(); + + +// A helper class for sniffing CSS features and creating CSS rules +// appropriate to the current rendering engine. +// +Monocle.Browser.css = new Monocle.CSS(); + + +// During Reader initialization, this method is called to create the +// "environment", which tests for the existence of various browser +// features and bugs, then invokes the callback to continue initialization. +// +// If the survey has already been conducted and the env exists, calls +// callback immediately. +// +Monocle.Browser.survey = function (callback) { + if (!Monocle.Browser.env) { + Monocle.Browser.env = new Monocle.Env(); + Monocle.Browser.env.survey(callback); + } else if (typeof callback == "function") { + callback(); + } +} +; +Gala = {} + + +// Register an event listener. +// +Gala.listen = function (elem, evtType, fn, useCapture) { + elem = Gala.$(elem); + if (elem.addEventListener) { + elem.addEventListener(evtType, fn, useCapture || false); + } else if (elem.attachEvent) { + if (evtType.indexOf(':') < 1) { + elem.attachEvent('on'+evtType, fn); + } else { + var h = (Gala.IE_REGISTRATIONS[elem] = Gala.IE_REGISTRATIONS[elem] || {}); + var a = (h[evtType] = h[evtType] || []); + a.push(fn); + } + } +} + + +// Remove an event listener. +// +Gala.deafen = function (elem, evtType, fn, useCapture) { + elem = Gala.$(elem); + if (elem.removeEventListener) { + elem.removeEventListener(evtType, fn, useCapture || false); + } else if (elem.detachEvent) { + if (evtType.indexOf(':') < 1) { + elem.detachEvent('on'+evtType, fn); + } else { + var h = (Gala.IE_REGISTRATIONS[elem] = Gala.IE_REGISTRATIONS[elem] || {}); + var a = (h[evtType] = h[evtType] || []); + for (var i = 0, ii = a.length; i < ii; ++i) { + if (a[i] == fn) { a.splice(i, 1); } + } + } + } +} + + +// Fire an event on the element. +// +// The data supplied to this function will be available in the event object in +// the 'm' property -- eg, alert(evt.m) --> 'foo' +// +Gala.dispatch = function (elem, evtType, data, cancelable) { + elem = Gala.$(elem); + if (elem.dispatchEvent) { + var evt = document.createEvent('Events'); + evt.initEvent(evtType, false, cancelable || false); + evt.m = data; + return elem.dispatchEvent(evt); + } else if (elem.attachEvent && evtType.indexOf(':') >= 0) { + if (!Gala.IE_REGISTRATIONS[elem]) { return true; } + var evtHandlers = Gala.IE_REGISTRATIONS[elem][evtType]; + if (!evtHandlers || evtHandlers.length < 1) { return true; } + var evt = { + type: evtType, + currentTarget: elem, + target: elem, + m: data, + defaultPrevented: false, + preventDefault: function () { evt.defaultPrevented = true; } + } + var q, processQueue = Gala.IE_INVOCATION_QUEUE.length == 0; + for (var i = 0, ii = evtHandlers.length; i < ii; ++i) { + q = { elem: elem, evtType: evtType, handler: evtHandlers[i], evt: evt } + Gala.IE_INVOCATION_QUEUE.push(q); + } + if (processQueue) { + while (q = Gala.IE_INVOCATION_QUEUE.shift()) { + //console.log("IE EVT on %s: '%s' with data: %s", q.elem, q.evtType, q.evt.m); + q.handler(q.evt); + } + } + return !(cancelable && evt.defaultPrevented); + } else { + console.warn('[GALA] Cannot dispatch non-namespaced events: '+evtType); + return true; + } +} + + +// Prevents the browser-default action on an event and stops it from +// propagating up the DOM tree. +// +Gala.stop = function (evt) { + evt = evt || window.event; + if (evt.preventDefault) { evt.preventDefault(); } + if (evt.stopPropagation) { evt.stopPropagation(); } + evt.returnValue = false; + evt.cancelBubble = true; + return false; +} + + +// Add a group of listeners, which is just a hash of { evtType: callback, ... } +// +Gala.listenGroup = function (elem, listeners, useCapture) { + for (evtType in listeners) { + Gala.listen(elem, evtType, listeners[evtType], useCapture || false); + } +} + + +// Remove a group of listeners. +// +Gala.deafenGroup = function (elem, listeners, useCapture) { + for (evtType in listeners) { + Gala.deafen(elem, evtType, listeners[evtType], useCapture || false); + } +} + + +// Replace a group of listeners with another group, re-using the same +// 'listeners' object -- a common pattern. +// +Gala.replaceGroup = function (elem, listeners, newListeners, useCapture) { + Gala.deafenGroup(elem, listeners, useCapture || false); + for (evtType in listeners) { delete listeners[evtType]; } + for (evtType in newListeners) { listeners[evtType] = newListeners[evtType]; } + Gala.listenGroup(elem, listeners, useCapture || false); + return listeners; +} + + +// Listen for a tap or a click event. +// +// Returns a 'listener' object that can be passed to Gala.deafenGroup(). +// +// If 'tapClass' is undefined, it defaults to 'tapping'. If it is a blank +// string, no class is assigned. +// +Gala.onTap = function (elem, fn, tapClass) { + elem = Gala.$(elem); + if (typeof tapClass == 'undefined') { tapClass = Gala.TAPPING_CLASS; } + var tapping = false; + var fns = { + start: function (evt) { + tapping = true; + if (tapClass) { elem.classList.add(tapClass); } + }, + move: function (evt) { + if (!tapping) { return; } + tapping = false; + if (tapClass) { elem.classList.remove(tapClass); } + }, + end: function (evt) { + if (!tapping) { return; } + fns.move(evt); + evt.currentTarget = evt.currentTarget || evt.srcElement; + fn(evt); + }, + noop: function (evt) {} + } + var noopOnClick = function (listeners) { + Gala.listen(elem, 'click', listeners.click = fns.noop); + } + Gala.listen(window, 'gala:contact:cancel', fns.move); + return Gala.onContact(elem, fns, false, noopOnClick); +} + + +// Register a series of functions to listen for the start, move, end +// events of a mouse or touch interaction. +// +// 'fns' argument is an object like: +// +// { +// 'start': function () { ... }, +// 'move': function () { ... }, +// 'end': function () { ... }, +// 'cancel': function () { ... } +// } +// +// All of the functions in this object are optional. +// +// Returns an object that can later be passed to Gala.deafenGroup. +// +Gala.onContact = function (elem, fns, useCapture, initCallback) { + elem = Gala.$(elem); + var listeners = null; + var inited = false; + + // If we see a touchstart event, register all these listeners. + var touchListeners = function () { + var l = {} + if (fns.start) { + l.touchstart = function (evt) { + if (evt.touches.length <= 1) { fns.start(evt); } + } + } + if (fns.move) { + l.touchmove = function (evt) { + if (evt.touches.length <= 1) { fns.move(evt); } + } + } + if (fns.end) { + l.touchend = function (evt) { + if (evt.touches.length <= 1) { fns.end(evt); } + } + } + if (fns.cancel) { + l.touchcancel = fns.cancel; + } + return l; + } + + // Whereas if we see a mousedown event, register all these listeners. + var mouseListeners = function () { + var l = {}; + if (fns.start) { + l.mousedown = function (evt) { if (evt.button < 2) { fns.start(evt); } } + } + if (fns.move) { + l.mousemove = fns.move; + } + if (fns.end) { + l.mouseup = function (evt) { if (evt.button < 2) { fns.end(evt); } } + } + // if (fns.cancel) { + // l.mouseout = function (evt) { + // obj = evt.relatedTarget || evt.fromElement; + // while (obj && (obj = obj.parentNode)) { if (obj == elem) { return; } } + // fns.cancel(evt); + // } + // } + return l; + } + + if (typeof Gala.CONTACT_MODE == 'undefined') { + var contactInit = function (evt, newListeners, mode) { + if (inited) { return; } + Gala.CONTACT_MODE = Gala.CONTACT_MODE || mode; + if (Gala.CONTACT_MODE != mode) { return; } + Gala.replaceGroup(elem, listeners, newListeners, useCapture); + if (typeof initCallback == 'function') { initCallback(listeners); } + if (listeners[evt.type]) { listeners[evt.type](evt); } + inited = true; + } + var touchInit = function (evt) { + contactInit(evt, touchListeners(), 'touch'); + } + var mouseInit = function (evt) { + contactInit(evt, mouseListeners(), 'mouse'); + } + listeners = { + 'touchstart': touchInit, + 'touchmove': touchInit, + 'touchend': touchInit, + 'touchcancel': touchInit, + 'mousedown': mouseInit, + 'mousemove': mouseInit, + 'mouseup': mouseInit, + 'mouseout': mouseInit + } + } else if (Gala.CONTACT_MODE == 'touch') { + listeners = touchListeners(); + } else if (Gala.CONTACT_MODE == 'mouse') { + listeners = mouseListeners(); + } + + Gala.listenGroup(elem, listeners); + if (typeof initCallback == 'function') { initCallback(listeners); } + return listeners; +} + + +// The Gala.Cursor object provides more detail coordinates for the contact +// event, and normalizes differences between touch and mouse coordinates. +// +// If you have a contact event listener, you can get the coordinates for it +// with: +// +// var cursor = new Gala.Cursor(evt); +// +Gala.Cursor = function (evt) { + var API = { constructor: Gala.Cursor } + + + function initialize() { + var ci = + evt.type.indexOf('mouse') == 0 ? evt : + ['touchstart', 'touchmove'].indexOf(evt.type) >= 0 ? evt.targetTouches[0] : + ['touchend', 'touchcancel'].indexOf(evt.type) >= 0 ? evt.changedTouches[0] : + null; + + // Basic coordinates (provided by the event). + API.pageX = ci.pageX; + API.pageY = ci.pageY; + API.clientX = ci.clientX; + API.clientY = ci.clientY; + API.screenX = ci.screenX; + API.screenY = ci.screenY; + + // Coordinates relative to the target element for the event. + var tgt = API.target = evt.target || evt.srcElement; + while (tgt.nodeType != 1 && tgt.parentNode) { tgt = tgt.parentNode; } + assignOffsetFor(tgt, 'offset'); + + // Coordinates relative to the element that the event was registered on. + var registrant = evt.currentTarget; + if (registrant && typeof registrant.offsetLeft != 'undefined') { + assignOffsetFor(registrant, 'registrant'); + } + } + + + function assignOffsetFor(elem, attr) { + var r; + if (elem.getBoundingClientRect) { + var er = elem.getBoundingClientRect(); + var dr = document.documentElement.getBoundingClientRect(); + r = { left: er.left - dr.left, top: er.top - dr.top } + } else { + r = { left: elem.offsetLeft, top: elem.offsetTop } + while (elem = elem.offsetParent) { + if (elem.offsetLeft || elem.offsetTop) { + r.left += elem.offsetLeft; + r.top += elem.offsetTop; + } + } + } + API[attr+'X'] = API.pageX - r.left; + API[attr+'Y'] = API.pageY - r.top; + } + + + initialize(); + + return API; +} + + +// A little utility to dereference ids into elements. You've seen this before. +// +Gala.$ = function (elem) { + if (typeof elem == 'string') { elem = document.getElementById(elem); } + return elem; +} + + + +// CONSTANTS +// +Gala.TAPPING_CLASS = 'tapping'; +Gala.IE_REGISTRATIONS = {} +Gala.IE_INVOCATION_QUEUE = [] +; +// A shortcut for creating a bookdata object from a 'data' hash. +// +// eg: +// +// Monocle.bookData({ components: ['intro.html', 'ch1.html', 'ch2.html'] }); +// +// All keys in the 'data' hash are optional: +// +// components: must be an array of component urls +// chapters: must be an array of nested chapters (the usual bookdata structure) +// metadata: must be a hash of key/values +// getComponentFn: override the default way to fetch components via id +// +Monocle.bookData = function (data) { + return { + getComponents: function () { + return data.components || ['anonymous']; + }, + getContents: function () { + return data.chapters || []; + }, + getComponent: data.getComponent || function (id) { + return { url: id } + }, + getMetaData: function (key) { + return (data.metadata || {})[key]; + }, + data: data + } +} + + +// A shortcut for creating a bookdata object from an array of element ids. +// +// eg: +// +// Monocle.bookDataFromIds(['part1', 'part2']); +// +Monocle.bookDataFromIds = function (elementIds) { + return Monocle.bookData({ + components: elementIds, + getComponent: function (cmptId) { + return { nodes: [document.getElementById(cmptId)] } + } + }); +} + + +// A shortcut for creating a bookdata object from an array of nodes. +// +// eg: +// +// Monocle.bookDataFromNodes([document.getElementById('content')]); +// +Monocle.bookDataFromNodes = function (nodes) { + return Monocle.bookData({ + getComponent: function (n) { return { 'nodes': nodes }; } + }); +} +; +Monocle.Factory = function (element, label, index, reader) { + + var API = { constructor: Monocle.Factory }; + var k = API.constants = API.constructor; + var p = API.properties = { + element: element, + label: label, + index: index, + reader: reader, + prefix: reader.properties.classPrefix || '' + } + + + // If index is null, uses the first available slot. If index is not null and + // the slot is not free, throws an error. + // + function initialize() { + if (!p.label) { return; } + // Append the element to the reader's graph of DOM elements. + var node = p.reader.properties.graph; + node[p.label] = node[p.label] || []; + if (typeof p.index == 'undefined' && node[p.label][p.index]) { + throw('Element already exists in graph: '+p.label+'['+p.index+']'); + } else { + p.index = p.index || node[p.label].length; + } + node[p.label][p.index] = p.element; + + // Add the label as a class name. + addClass(p.label); + } + + + // Finds an element that has been created in the context of the current + // reader, with the given label. If oIndex is not provided, returns first. + // If oIndex is provided (eg, n), returns the nth element with the label. + // + function find(oLabel, oIndex) { + if (!p.reader.properties.graph[oLabel]) { + return null; + } + return p.reader.properties.graph[oLabel][oIndex || 0]; + } + + + // Takes an elements and assimilates it into the reader -- essentially + // giving it a "dom" object of it's own. It will then be accessible via find. + // + // Note that (as per comments for initialize), if oIndex is provided and + // there is no free slot in the array for this label at that index, an + // error will be thrown. + // + function claim(oElement, oLabel, oIndex) { + return oElement.dom = new Monocle.Factory( + oElement, + oLabel, + oIndex, + p.reader + ); + } + + + // Create an element with the given label. + // + // The last argument (whether third or fourth) is the options hash. Your + // options are: + // + // class - the classname for the element. Must only be one name. + // html - the innerHTML for the element. + // text - the innerText for the element (an alternative to html, simpler). + // + // Returns the created element. + // + function make(tagName, oLabel, index_or_options, or_options) { + var oIndex, options; + if (arguments.length == 1) { + oLabel = null, + oIndex = 0; + options = {}; + } else if (arguments.length == 2) { + oIndex = 0; + options = {}; + } else if (arguments.length == 4) { + oIndex = arguments[2]; + options = arguments[3]; + } else if (arguments.length == 3) { + var lastArg = arguments[arguments.length - 1]; + if (typeof lastArg == "number") { + oIndex = lastArg; + options = {}; + } else { + oIndex = 0; + options = lastArg; + } + } + + var oElement = document.createElement(tagName); + claim(oElement, oLabel, oIndex); + if (options['class']) { + oElement.className += " "+p.prefix+options['class']; + } + if (options['html']) { + oElement.innerHTML = options['html']; + } + if (options['text']) { + oElement.appendChild(document.createTextNode(options['text'])); + } + + return oElement; + } + + + // Creates an element by passing all the given arguments to make. Then + // appends the element as a child of the current element. + // + function append(tagName, oLabel, index_or_options, or_options) { + var oElement = make.apply(this, arguments); + p.element.appendChild(oElement); + return oElement; + } + + + // Returns an array of [label, index, reader] for the given element. + // A simple way to introspect the arguments required for #find, for eg. + // + function address() { + return [p.label, p.index, p.reader]; + } + + + // Apply a set of style rules (hash or string) to the current element. + // See Monocle.Styles.applyRules for more info. + // + function setStyles(rules) { + return Monocle.Styles.applyRules(p.element, rules); + } + + + function setBetaStyle(property, value) { + return Monocle.Styles.affix(p.element, property, value); + } + + + // ClassName manipulation functions - with thanks to prototype.js! + + // Returns true if one of the current element's classnames matches name -- + // classPrefix aware (so don't concate the prefix onto it). + // + function hasClass(name) { + name = p.prefix + name; + var klass = p.element.className; + if (!klass) { return false; } + if (klass == name) { return true; } + return new RegExp("(^|\\s)"+name+"(\\s|$)").test(klass); + } + + + // Adds name to the classnames of the current element (prepending the + // reader's classPrefix first). + // + function addClass(name) { + if (hasClass(name)) { return; } + var gap = p.element.className ? ' ' : ''; + return p.element.className += gap+p.prefix+name; + } + + + // Removes (classPrefix+)name from the classnames of the current element. + // + function removeClass(name) { + var reName = new RegExp("(^|\\s+)"+p.prefix+name+"(\\s+|$)"); + var reTrim = /^\s+|\s+$/g; + var klass = p.element.className; + p.element.className = klass.replace(reName, ' ').replace(reTrim, ''); + return p.element.className; + } + + + API.find = find; + API.claim = claim; + API.make = make; + API.append = append; + API.address = address; + + API.setStyles = setStyles; + API.setBetaStyle = setBetaStyle; + API.hasClass = hasClass; + API.addClass = addClass; + API.removeClass = removeClass; + + initialize(); + + return API; +} +; +Monocle.Events = {}; + + +Monocle.Events.wrapper = function (fn) { + return function (evt) { evt.m = new Gala.Cursor(evt); fn(evt); } +} + + +Monocle.Events.listen = Gala.listen; + + +Monocle.Events.deafen = Gala.deafen; + + +Monocle.Events.dispatch = Gala.dispatch; + + +Monocle.Events.listenForTap = function (elem, fn, tapClass) { + return Gala.onTap(elem, Monocle.Events.wrapper(fn), tapClass); +} + + +Monocle.Events.deafenForTap = Gala.deafenGroup; + + +Monocle.Events.listenForContact = function (elem, fns, options) { + options = options || { useCapture: false }; + var wrappers = {}; + for (evtType in fns) { + wrappers[evtType] = Monocle.Events.wrapper(fns[evtType]); + } + return Gala.onContact(elem, wrappers, options.useCapture); +} + + +Monocle.Events.deafenForContact = Gala.deafenGroup; + + +// Listen for the next transition-end event on the given element, call +// the function, then deafen. +// +// Returns a function that can be used to cancel the listen early. +// +Monocle.Events.afterTransition = function (elem, fn) { + var evtName = "transitionend"; + if (Monocle.Browser.is.WebKit) { + evtName = 'webkitTransitionEnd'; + } else if (Monocle.Browser.is.Opera) { + evtName = 'oTransitionEnd'; + } + var l = null, cancel = null; + l = function () { fn(); cancel(); } + cancel = function () { Monocle.Events.deafen(elem, evtName, l); } + Monocle.Events.listen(elem, evtName, l); + return cancel; +} +; +Monocle.Styles = { + + // Takes a hash and returns a string. + rulesToString: function (rules) { + if (typeof rules != 'string') { + var parts = []; + for (var declaration in rules) { + parts.push(declaration+": "+rules[declaration]+";") + } + rules = parts.join(" "); + } + return rules; + }, + + + // Takes a hash or string of CSS property assignments and applies them + // to the element. + // + applyRules: function (elem, rules) { + rules = Monocle.Styles.rulesToString(rules); + elem.style.cssText += ';'+rules; + return elem.style.cssText; + }, + + + // Generates cross-browser properties for a given property. + // ie, affix(, 'transition', 'linear 100ms') would apply that value + // to webkitTransition for WebKit browsers, and to MozTransition for Gecko. + // + affix: function (elem, property, value) { + var target = elem.style ? elem.style : elem; + var props = Monocle.Browser.css.toDOMProps(property); + while (props.length) { target[props.shift()] = value; } + }, + + + setX: function (elem, x) { + var s = elem.style; + if (typeof x == "number") { x += "px"; } + var val = Monocle.Browser.env.supportsTransform3d ? + 'translate3d('+x+', 0, 0)' : + 'translateX('+x+')'; + val = (x == '0px') ? 'none' : val; + s.webkitTransform = s.MozTransform = s.OTransform = s.transform = val; + return x; + }, + + + setY: function (elem, y) { + var s = elem.style; + if (typeof y == "number") { y += "px"; } + var val = Monocle.Browser.env.supportsTransform3d ? + 'translate3d(0, '+y+', 0)' : + 'translateY('+y+')'; + val = (y == '0px') ? 'none' : val; + s.webkitTransform = s.MozTransform = s.OTransform = s.transform = val; + return y; + }, + + + getX: function (elem) { + var currStyle = document.defaultView.getComputedStyle(elem, null); + var re = /matrix\([^,]+,[^,]+,[^,]+,[^,]+,\s*([^,]+),[^\)]+\)/; + var props = Monocle.Browser.css.toDOMProps('transform'); + var matrix = null; + while (props.length && !matrix) { + matrix = currStyle[props.shift()]; + } + return parseInt(matrix.match(re)[1]); + }, + + + transitionFor: function (elem, prop, duration, timing, delay) { + var tProps = Monocle.Browser.css.toDOMProps('transition'); + var pProps = Monocle.Browser.css.toCSSProps(prop); + timing = timing || "linear"; + delay = (delay || 0)+"ms"; + for (var i = 0, ii = tProps.length; i < ii; ++i) { + var t = "none"; + if (duration) { + t = [pProps[i], duration+"ms", timing, delay].join(" "); + } + elem.style[tProps[i]] = t; + } + } + +} + + +// These rule definitions are more or less compulsory for Monocle to behave +// as expected. Which is why they appear here and not in the stylesheet. +// Adjust them if you know what you're doing. +// +Monocle.Styles.container = { + "position": "absolute", + "overflow": "hidden", + "top": "0", + "left": "0", + "bottom": "0", + "right": "0" +} + +Monocle.Styles.page = { + "position": "absolute", + "z-index": "1", + "-webkit-user-select": "none", + "-moz-user-select": "none", + "-ms-user-select": "none", + "user-select": "none", + "-webkit-transform": "translate3d(0,0,0)", + "visibility": "visible" + + /* + "background": "white", + "top": "0", + "left": "0", + "bottom": "0", + "right": "0" + */ +} + +Monocle.Styles.sheaf = { + "position": "absolute", + "overflow": "hidden" + + /* + "top": "0", + "left": "0", + "bottom": "0", + "right": "0" + */ +} + +Monocle.Styles.component = { + "width": "100%", + "height": "100%", + "border": "none", + "-webkit-user-select": "none", + "-moz-user-select": "none", + "-ms-user-select": "none", + "user-select": "none" +} + +Monocle.Styles.control = { + "z-index": "100", + "cursor": "pointer" +} + +Monocle.Styles.overlay = { + "position": "absolute", + "display": "none", + "width": "100%", + "height": "100%", + "z-index": "1000" +} +; +Monocle.Formatting = function (reader, optStyles, optScale) { + var API = { constructor: Monocle.Formatting }; + var k = API.constants = API.constructor; + var p = API.properties = { + reader: reader, + + // An array of style rules that are automatically applied to every page. + stylesheets: [], + + // A multiplier on the default font-size of each element in every + // component. If null, the multiplier is not applied (or it is removed). + fontScale: null + } + + + function initialize() { + p.fontScale = optScale; + clampStylesheets(optStyles); + p.reader.listen('monocle:componentmodify', persistOnComponentChange); + } + + + // Clamp page frames to a set of styles that reduce Monocle breakage. + // + function clampStylesheets(implStyles) { + var defCSS = k.DEFAULT_STYLE_RULES; + if (Monocle.Browser.env.floatsIgnoreColumns) { + defCSS.push("html#RS\\:monocle * { float: none !important; }"); + } + p.defaultStyles = addPageStyles(defCSS, false); + if (implStyles) { + p.initialStyles = addPageStyles(implStyles, false); + } + } + + + function persistOnComponentChange(evt) { + var doc = evt.m['document']; + doc.documentElement.id = p.reader.properties.systemId; + adjustFontScaleForDoc(doc, p.fontScale); + for (var i = 0; i < p.stylesheets.length; ++i) { + if (p.stylesheets[i]) { + addPageStylesheet(doc, i); + } + } + } + + + /* PAGE STYLESHEETS */ + + // API for adding a new stylesheet to all components. styleRules should be + // a string of CSS rules. restorePlace defaults to true. + // + // Returns a sheet index value that can be used with updatePageStyles + // and removePageStyles. + // + function addPageStyles(styleRules, restorePlace) { + return changingStylesheet(function () { + p.stylesheets.push(styleRules); + var sheetIndex = p.stylesheets.length - 1; + + var i = 0, cmpt = null; + while (cmpt = p.reader.dom.find('component', i++)) { + addPageStylesheet(cmpt.contentDocument, sheetIndex); + } + return sheetIndex; + }, restorePlace); + } + + + // API for updating the styleRules in an existing page stylesheet across + // all components. Takes a sheet index value obtained via addPageStyles. + // + function updatePageStyles(sheetIndex, styleRules, restorePlace) { + return changingStylesheet(function () { + p.stylesheets[sheetIndex] = styleRules; + if (typeof styleRules.join == "function") { + styleRules = styleRules.join("\n"); + } + + var i = 0, cmpt = null; + while (cmpt = p.reader.dom.find('component', i++)) { + var doc = cmpt.contentDocument; + var styleTag = doc.getElementById('monStylesheet'+sheetIndex); + if (!styleTag) { + console.warn('No such stylesheet: ' + sheetIndex); + return; + } + if (styleTag.styleSheet) { + styleTag.styleSheet.cssText = styleRules; + } else { + styleTag.replaceChild( + doc.createTextNode(styleRules), + styleTag.firstChild + ); + } + } + }, restorePlace); + } + + + // API for removing a page stylesheet from all components. Takes a sheet + // index value obtained via addPageStyles. + // + function removePageStyles(sheetIndex, restorePlace) { + return changingStylesheet(function () { + p.stylesheets[sheetIndex] = null; + var i = 0, cmpt = null; + while (cmpt = p.reader.dom.find('component', i++)) { + var doc = cmpt.contentDocument; + var styleTag = doc.getElementById('monStylesheet'+sheetIndex); + styleTag.parentNode.removeChild(styleTag); + } + }, restorePlace); + } + + + // Wraps all API-based stylesheet changes (add, update, remove) in a + // brace of custom events (stylesheetchanging/stylesheetchange), and + // recalculates component dimensions if specified (default to true). + // + function changingStylesheet(callback, restorePlace) { + restorePlace = (restorePlace === false) ? false : true; + if (restorePlace) { + dispatchChanging(); + } + var result = callback(); + if (restorePlace) { + p.reader.recalculateDimensions(true); + Monocle.defer(dispatchChange); + } else { + p.reader.recalculateDimensions(false); + } + return result; + } + + + function dispatchChanging() { + p.reader.dispatchEvent("monocle:stylesheetchanging", {}); + } + + + function dispatchChange() { + p.reader.dispatchEvent("monocle:stylesheetchange", {}); + } + + + // Private method for adding a stylesheet to a component. Used by + // addPageStyles. + // + function addPageStylesheet(doc, sheetIndex) { + var styleRules = p.stylesheets[sheetIndex]; + + if (!styleRules) { + return; + } + + if (!doc || !doc.documentElement) { + return; + } + + var head = doc.getElementsByTagName('head')[0]; + if (!head) { + head = doc.createElement('head'); + doc.documentElement.appendChild(head); + } + + if (typeof styleRules.join == "function") { + styleRules = styleRules.join("\n"); + } + + var styleTag = doc.createElement('style'); + styleTag.type = 'text/css'; + styleTag.id = "monStylesheet"+sheetIndex; + if (styleTag.styleSheet) { + styleTag.styleSheet.cssText = styleRules; + } else { + styleTag.appendChild(doc.createTextNode(styleRules)); + } + + head.appendChild(styleTag); + + return styleTag; + } + + + /* FONT SCALING */ + + function setFontScale(scale, restorePlace) { + p.fontScale = scale; + if (restorePlace) { + dispatchChanging(); + } + var i = 0, cmpt = null; + while (cmpt = p.reader.dom.find('component', i++)) { + adjustFontScaleForDoc(cmpt.contentDocument, scale); + } + if (restorePlace) { + p.reader.recalculateDimensions(true); + dispatchChange(); + } else { + p.reader.recalculateDimensions(false); + } + } + + + function adjustFontScaleForDoc(doc, scale) { + var elems = doc.getElementsByTagName('*'); + if (scale) { + scale = parseFloat(scale); + if (!doc.body.pfsSwept) { + sweepElements(doc, elems); + } + + // Iterate over each element, applying scale to the original + // font-size. If a proportional font sizing is already applied to + // the element, update existing cssText, otherwise append new cssText. + // + for (var j = 0, jj = elems.length; j < jj; ++j) { + var newFs = fsProperty(elems[j].pfsOriginal, scale); + if (elems[j].pfsApplied) { + replaceFontSizeInStyle(elems[j], newFs); + } else { + elems[j].style.cssText += newFs; + } + elems[j].pfsApplied = scale; + } + } else if (doc.body.pfsSwept) { + // Iterate over each element, removing proportional font-sizing flag + // and property from cssText. + for (var j = 0, jj = elems.length; j < jj; ++j) { + if (elems[j].pfsApplied) { + var oprop = elems[j].pfsOriginalProp; + var opropDec = oprop ? 'font-size: '+oprop+' ! important;' : ''; + replaceFontSizeInStyle(elems[j], opropDec); + elems[j].pfsApplied = null; + } + } + + // Establish new baselines in case classes have changed. + sweepElements(doc, elems); + } + } + + + function sweepElements(doc, elems) { + // Iterate over each element, looking at its font size and storing + // the original value against the element. + for (var i = 0, ii = elems.length; i < ii; ++i) { + var currStyle = doc.defaultView.getComputedStyle(elems[i], null); + var fs = parseFloat(currStyle.getPropertyValue('font-size')); + elems[i].pfsOriginal = fs; + elems[i].pfsOriginalProp = elems[i].style.fontSize; + } + doc.body.pfsSwept = true; + } + + + function fsProperty(orig, scale) { + return 'font-size: '+(orig*scale)+'px ! important;'; + } + + + function replaceFontSizeInStyle(elem, newProp) { + var lastFs = /font-size:[^;]/ + elem.style.cssText = elem.style.cssText.replace(lastFs, newProp); + } + + + API.addPageStyles = addPageStyles; + API.updatePageStyles = updatePageStyles; + API.removePageStyles = removePageStyles; + API.setFontScale = setFontScale; + + initialize(); + + return API; +} + + + +Monocle.Formatting.DEFAULT_STYLE_RULES = [ + "html#RS\\:monocle * {" + + "-webkit-font-smoothing: subpixel-antialiased;" + + "text-rendering: auto !important;" + + "word-wrap: break-word !important;" + + "overflow: visible !important;" + + "}", + "html#RS\\:monocle body {" + + "margin: 0 !important;"+ + "border: none !important;" + + "padding: 0 !important;" + + "width: 100% !important;" + + "position: absolute !important;" + + "-webkit-text-size-adjust: none;" + + "}", + "html#RS\\:monocle body * {" + + "max-width: 100% !important;" + + "}", + "html#RS\\:monocle img, html#RS\\:monocle video, html#RS\\:monocle object, html#RS\\:monocle svg {" + + "max-height: 95% !important;" + + "height: auto !important;" + + "}" +] +; +// READER +// +// +// The full DOM hierarchy created by Reader is: +// +// box +// -> container +// -> pages (the number of page elements is determined by the flipper) +// -> sheaf (basically just sets the margins) +// -> component (an iframe created by the current component) +// -> body (the document.body of the iframe) +// -> page controls +// -> standard controls +// -> overlay +// -> modal/popover controls +// +// +// Options: +// +// flipper: The class of page flipper to use. +// +// panels: The class of panels to use +// +// stylesheet: A string of CSS rules to apply to the contents of each +// component loaded into the reader. +// +// fontScale: a float to multiply against the default font-size of each +// element in each component. +// +// place: A book locus for the page to open to when the reader is +// initialized. (See comments at Book#pageNumberAt for more about +// the locus option). +// +// systemId: the id for root elements of components, defaults to "RS:monocle" +// +Monocle.Reader = function (node, bookData, options, onLoadCallback) { + + var API = { constructor: Monocle.Reader } + var k = API.constants = API.constructor; + var p = API.properties = { + // Initialization-completed flag. + initialized: false, + + // The active book. + book: null, + + // DOM graph of factory-generated objects. + graph: {}, + + // Id applied to the HTML element of each component, can be used to scope + // CSS rules. + systemId: (options ? options.systemId : null) || k.DEFAULT_SYSTEM_ID, + + // Prefix for classnames for any created element. + classPrefix: k.DEFAULT_CLASS_PREFIX, + + // Registered control objects (see addControl). Hashes of the form: + // { + // control: , + // elements: , + // controlType: + // } + controls: [], + + // After the reader has been resized, this resettable timer must expire + // the place is restored. + resizeTimer: null + } + + var dom; + + + // Inspects the browser environment and kicks off preparing the container. + // + function initialize() { + options = options || {} + + Monocle.Browser.survey(prepareBox); + } + + + // Sets up the container and internal elements. + // + function prepareBox() { + var box = node; + if (typeof box == "string") { box = document.getElementById(box); } + dom = API.dom = box.dom = new Monocle.Factory(box, 'box', 0, API); + + API.billboard = new Monocle.Billboard(API); + + if (!Monocle.Browser.env.isCompatible()) { + if (dispatchEvent("monocle:incompatible", {}, true)) { + fatalSystemMessage(k.COMPATIBILITY_INFO); + } + return; + } + + dispatchEvent("monocle:initializing", API); + + bookData = bookData || Monocle.bookDataFromNodes([box.cloneNode(true)]); + var bk = new Monocle.Book(bookData, options.preloadWindow || 1); + + box.innerHTML = ""; + + // Make sure the box div is absolutely or relatively positioned. + positionBox(); + + // Attach the page-flipping gadget. + attachFlipper(options.flipper); + + // Create the essential DOM elements. + createReaderElements(); + + // Create the selection object. + API.selection = new Monocle.Selection(API); + + // Create the formatting object. + API.formatting = new Monocle.Formatting( + API, + options.stylesheet, + options.fontScale + ); + + primeFrames(options.primeURL, function () { + // Make the reader elements look pretty. + applyStyles(); + + p.flipper.listenForInteraction(options.panels); + + setBook(bk, options.place, function () { + if (onLoadCallback) { onLoadCallback(API); } + dispatchEvent("monocle:loaded", API); + }); + }); + } + + + function positionBox() { + var currPosVal; + var box = dom.find('box'); + if (document.defaultView) { + var currStyle = document.defaultView.getComputedStyle(box, null); + currPosVal = currStyle.getPropertyValue('position'); + } else if (box.currentStyle) { + currPosVal = box.currentStyle.position + } + if (["absolute", "relative"].indexOf(currPosVal) == -1) { + box.style.position = "relative"; + } + } + + + function attachFlipper(flipperClass) { + if (!flipperClass) { + if (Monocle.Browser.renders.slow) { + flipperClass = Monocle.Flippers.Instant; + } else { + flipperClass = Monocle.Flippers.Slider; + } + } + + p.flipper = new flipperClass(API, null, p.readerOptions); + } + + + function createReaderElements() { + var cntr = dom.append('div', 'container'); + for (var i = 0; i < p.flipper.pageCount; ++i) { + var page = cntr.dom.append('div', 'page', i); + page.style.visibility = "hidden"; + page.m = { reader: API, pageIndex: i, place: null } + page.m.sheafDiv = page.dom.append('div', 'sheaf', i); + page.m.activeFrame = page.m.sheafDiv.dom.append('iframe', 'component', i); + page.m.activeFrame.m = { 'pageDiv': page }; + page.m.activeFrame.setAttribute('frameBorder', 0); + page.m.activeFrame.setAttribute('scrolling', 'no'); + p.flipper.addPage(page); + } + dom.append('div', 'overlay'); + dispatchEvent("monocle:loading", API); + } + + + // Opens the frame to a particular URL (usually 'about:blank'). + // + function primeFrames(url, callback) { + url = url || (Monocle.Browser.on.UIWebView ? "blank.html" : "about:blank"); + + var pageCount = 0; + + var cb = function (evt) { + var frame = evt.target || evt.srcElement; + Monocle.Events.deafen(frame, 'load', cb); + dispatchEvent( + 'monocle:frameprimed', + { frame: frame, pageIndex: pageCount } + ); + if ((pageCount += 1) == p.flipper.pageCount) { + Monocle.defer(callback); + } + } + + forEachPage(function (page) { + Monocle.Events.listen(page.m.activeFrame, 'load', cb); + page.m.activeFrame.src = url; + }); + } + + + function applyStyles() { + dom.find('container').dom.setStyles(Monocle.Styles.container); + forEachPage(function (page, i) { + page.dom.setStyles(Monocle.Styles.page); + dom.find('sheaf', i).dom.setStyles(Monocle.Styles.sheaf); + var cmpt = dom.find('component', i) + cmpt.dom.setStyles(Monocle.Styles.component); + }); + lockFrameWidths(); + dom.find('overlay').dom.setStyles(Monocle.Styles.overlay); + dispatchEvent('monocle:styles'); + } + + + function lockingFrameWidths() { + if (!Monocle.Browser.env.relativeIframeExpands) { return; } + for (var i = 0, cmpt; cmpt = dom.find('component', i); ++i) { + cmpt.style.display = "none"; + } + } + + + function lockFrameWidths() { + if (!Monocle.Browser.env.relativeIframeExpands) { return; } + for (var i = 0, cmpt; cmpt = dom.find('component', i); ++i) { + cmpt.style.width = cmpt.parentNode.offsetWidth+"px"; + cmpt.style.display = "block"; + } + } + + + // Apply the book, move to a particular place or just the first page, wait + // for everything to complete, then fire the callback. + // + function setBook(bk, place, callback) { + p.book = bk; + var pageCount = 0; + if (typeof callback == 'function') { + var watchers = { + 'monocle:componentchange': function (evt) { + dispatchEvent('monocle:firstcomponentchange', evt.m); + return (pageCount += 1) == p.flipper.pageCount; + }, + 'monocle:componentfailed': function (evt) { + fatalSystemMessage(k.LOAD_FAILURE_INFO); + return true; + }, + 'monocle:turn': function (evt) { + deafen('monocle:componentfailed', listener); + callback(); + return true; + } + } + var listener = function (evt) { + if (watchers[evt.type](evt)) { deafen(evt.type, listener); } + } + for (evtType in watchers) { listen(evtType, listener) } + } + p.flipper.moveTo(place || { page: 1 }, initialized); + } + + + function getBook() { + return p.book; + } + + + function initialized() { + p.initialized = true; + } + + + // Attempts to restore the place we were up to in the book before the + // reader was resized. + // + // The delay ensures that if we get multiple calls to this function in + // a short period, we don't do lots of expensive recalculations. + // + function resized() { + if (!p.initialized) { + console.warn('Attempt to resize book before initialization.'); + } + lockingFrameWidths(); + if (!dispatchEvent("monocle:resizing", {}, true)) { + return; + } + clearTimeout(p.resizeTimer); + p.resizeTimer = Monocle.defer(performResize, k.RESIZE_DELAY); + } + + + function performResize() { + lockFrameWidths(); + recalculateDimensions(true, afterResized); + } + + + function afterResized() { + dispatchEvent('monocle:resize'); + } + + + function recalculateDimensions(andRestorePlace, callback) { + if (!p.book) { return; } + dispatchEvent("monocle:recalculating"); + + var place, locus; + if (andRestorePlace !== false) { + var place = getPlace(); + var locus = { percent: place ? place.percentageThrough() : 0 }; + } + + forEachPage(function (pageDiv) { + pageDiv.m.activeFrame.m.component.updateDimensions(pageDiv); + }); + + var cb = function () { + dispatchEvent("monocle:recalculated"); + Monocle.defer(callback); + } + Monocle.defer(function () { locus ? p.flipper.moveTo(locus, cb) : cb; }); + } + + + // Returns the current page number in the book. + // + // The pageDiv argument is optional - typically defaults to whatever the + // flipper thinks is the "active" page. + // + function pageNumber(pageDiv) { + var place = getPlace(pageDiv); + return place ? (place.pageNumber() || 1) : 1; + } + + + // Returns the current "place" in the book -- ie, the page number, chapter + // title, etc. + // + // The pageDiv argument is optional - typically defaults to whatever the + // flipper thinks is the "active" page. + // + function getPlace(pageDiv) { + if (!p.initialized) { + console.warn('Attempt to access place before initialization.'); + } + return p.flipper.getPlace(pageDiv); + } + + + // Moves the current page as specified by the locus. See + // Monocle.Book#pageNumberAt for documentation on the locus argument. + // + // The callback argument is optional. + // + function moveTo(locus, callback) { + if (!p.initialized) { + console.warn('Attempt to move place before initialization.'); + } + if (!p.book.isValidLocus(locus)) { + dispatchEvent( + "monocle:notfound", + { href: locus ? locus.componentId : "anonymous" } + ); + return false; + } + var fn = callback; + if (!locus.direction) { + dispatchEvent('monocle:jumping', { locus: locus }); + fn = function () { + dispatchEvent('monocle:jump', { locus: locus }); + if (callback) { callback(); } + } + } + p.flipper.moveTo(locus, fn); + return true; + } + + + // Moves to the relevant element in the relevant component. + // + function skipToChapter(src) { + var locus = p.book.locusOfChapter(src); + return moveTo(locus); + } + + + // Valid types: + // - standard (an overlay above the pages) + // - page (within the page) + // - modal (overlay where click-away does nothing, for a single control) + // - hud (overlay that multiple controls can share) + // - popover (overlay where click-away removes the ctrl elements) + // - invisible + // + // Options: + // - hidden -- creates and hides the ctrl elements; + // use showControl to show them + // - container -- specify an existing DOM element to contain the control. + // + function addControl(ctrl, cType, options) { + for (var i = 0; i < p.controls.length; ++i) { + if (p.controls[i].control == ctrl) { + console.warn("Already added control: %o", ctrl); + return; + } + } + + options = options || {}; + + var ctrlData = { control: ctrl, elements: [], controlType: cType } + p.controls.push(ctrlData); + + var addControlTo = function (cntr) { + if (cntr == 'container') { + cntr = options.container || dom.find('container'); + if (typeof cntr == 'string') { cntr = document.getElementById(cntr); } + if (!cntr.dom) { dom.claim(cntr, 'controlContainer'); } + } else if (cntr == 'overlay') { + cntr = dom.find('overlay'); + } + if (typeof ctrl.createControlElements != 'function') { return; } + var ctrlElem = ctrl.createControlElements(cntr); + if (!ctrlElem) { return; } + cntr.appendChild(ctrlElem); + ctrlData.elements.push(ctrlElem); + Monocle.Styles.applyRules(ctrlElem, Monocle.Styles.control); + return ctrlElem; + } + + if (!cType || cType == 'standard' || cType == 'invisible') { + addControlTo('container'); + } else if (cType == 'page') { + forEachPage(addControlTo); + } else if (cType == 'modal' || cType == 'popover' || cType == 'hud') { + addControlTo('overlay'); + ctrlData.usesOverlay = true; + } else if (cType == 'invisible') { + addControlTo('container'); + } else { + console.warn('Unknown control type: ' + cType); + } + + if (options.hidden) { + hideControl(ctrl); + } else { + showControl(ctrl); + } + + if (typeof ctrl.assignToReader == 'function') { + ctrl.assignToReader(API); + } + + return ctrl; + } + + + function dataForControl(ctrl) { + for (var i = 0; i < p.controls.length; ++i) { + if (p.controls[i].control == ctrl) { + return p.controls[i]; + } + } + } + + + function hideControl(ctrl) { + var controlData = dataForControl(ctrl); + if (!controlData) { + console.warn("No data for control: " + ctrl); + return; + } + if (controlData.hidden) { + return; + } + for (var i = 0; i < controlData.elements.length; ++i) { + controlData.elements[i].style.display = "none"; + } + if (controlData.usesOverlay) { + var overlay = dom.find('overlay'); + overlay.style.display = "none"; + Monocle.Events.deafenForContact(overlay, overlay.listeners); + if (controlData.controlType != 'hud') { + dispatchEvent('monocle:modal:off'); + } + } + controlData.hidden = true; + if (ctrl.properties) { + ctrl.properties.hidden = true; + } + dispatchEvent('monocle:controlhide', { control: ctrl }, false); + } + + + function showControl(ctrl) { + var controlData = dataForControl(ctrl); + if (!controlData) { + console.warn("No data for control: " + ctrl); + return false; + } + + if (showingControl(ctrl)) { + return false; + } + + var overlay = dom.find('overlay'); + if (controlData.usesOverlay && controlData.controlType != "hud") { + for (var i = 0, ii = p.controls.length; i < ii; ++i) { + if (p.controls[i].usesOverlay && !p.controls[i].hidden) { + return false; + } + } + overlay.style.display = "block"; + dispatchEvent('monocle:modal:on'); + } + + for (var i = 0; i < controlData.elements.length; ++i) { + controlData.elements[i].style.display = "block"; + } + + if (controlData.controlType == "popover") { + var onControl = function (evt) { + var obj = evt.target; + do { + if (obj == controlData.elements[0]) { return true; } + } while (obj && (obj = obj.parentNode)); + return false; + } + overlay.listeners = Monocle.Events.listenForContact( + overlay, + { + start: function (evt) { if (!onControl(evt)) { hideControl(ctrl); } }, + move: function (evt) { if (!onControl(evt)) { evt.preventDefault(); } } + } + ); + } + controlData.hidden = false; + if (ctrl.properties) { + ctrl.properties.hidden = false; + } + dispatchEvent('monocle:controlshow', { control: ctrl }, false); + return true; + } + + + function showingControl(ctrl) { + var controlData = dataForControl(ctrl); + return controlData.hidden == false; + } + + + function dispatchEvent(evtType, data, cancelable) { + return Monocle.Events.dispatch(dom.find('box'), evtType, data, cancelable); + } + + + function listen(evtType, fn, useCapture) { + Monocle.Events.listen(dom.find('box'), evtType, fn, useCapture); + } + + + function deafen(evtType, fn) { + Monocle.Events.deafen(dom.find('box'), evtType, fn); + } + + + function visiblePages() { + return p.flipper.visiblePages ? + p.flipper.visiblePages() : + [dom.find('page')]; + } + + + function forEachPage(callback) { + for (var i = 0, ii = p.flipper.pageCount; i < ii; ++i) { + var page = dom.find('page', i); + callback(page, i); + } + } + + + /* The Reader PageStyles API is deprecated - it has moved to Formatting */ + + function addPageStyles(styleRules, restorePlace) { + console.deprecation("Use reader.formatting.addPageStyles instead."); + return API.formatting.addPageStyles(styleRules, restorePlace); + } + + + function updatePageStyles(sheetIndex, styleRules, restorePlace) { + console.deprecation("Use reader.formatting.updatePageStyles instead."); + return API.formatting.updatePageStyles(sheetIndex, styleRules, restorePlace); + } + + + function removePageStyles(sheetIndex, restorePlace) { + console.deprecation("Use reader.formatting.removePageStyles instead."); + return API.formatting.removePageStyles(sheetIndex, restorePlace); + } + + + function fatalSystemMessage(msg) { + var info = dom.make('div', 'book_fatality', { html: msg }); + var box = dom.find('box'); + var bbOrigin = [box.offsetWidth / 2, box.offsetHeight / 2]; + API.billboard.show(info, { closeButton: false, from: bbOrigin }); + } + + + API.getBook = getBook; + API.getPlace = getPlace; + API.moveTo = moveTo; + API.skipToChapter = skipToChapter; + API.resized = resized; + API.recalculateDimensions = recalculateDimensions; + API.addControl = addControl; + API.hideControl = hideControl; + API.showControl = showControl; + API.showingControl = showingControl; + API.dispatchEvent = dispatchEvent; + API.listen = listen; + API.deafen = deafen; + API.visiblePages = visiblePages; + + // Deprecated! + API.addPageStyles = addPageStyles; + API.updatePageStyles = updatePageStyles; + API.removePageStyles = removePageStyles; + + initialize(); + + return API; +} + + +Monocle.Reader.RESIZE_DELAY = Monocle.Browser.renders.slow ? 500 : 100; +Monocle.Reader.DEFAULT_SYSTEM_ID = 'RS:monocle' +Monocle.Reader.DEFAULT_CLASS_PREFIX = 'monelem_' +Monocle.Reader.DEFAULT_STYLE_RULES = Monocle.Formatting.DEFAULT_STYLE_RULES; +Monocle.Reader.COMPATIBILITY_INFO = + "

Incompatible browser

"+ + "

Unfortunately, your browser isn't able to display this book. "+ + "If possible, try again in another browser or on another device.

"; +Monocle.Reader.LOAD_FAILURE_INFO = + "

Book could not be loaded

"+ + "

Sorry, parts of the book could not be retrieved.
"+ + "Please check your connection and refresh to try again.

"; +/* BOOK */ + +/* The Book handles movement through the content by the reader page elements. + * + * It's responsible for instantiating components as they are required, + * and for calculating which component and page number to move to (based on + * requests from the Reader). + * + */ + +Monocle.Book = function (dataSource, preloadWindow) { + + var API = { constructor: Monocle.Book } + var k = API.constants = API.constructor; + var p = API.properties = { + dataSource: dataSource, + preloadWindow: preloadWindow, + cmptLoadQueue: {}, + components: [], + chapters: {} // flat arrays of chapters per component + } + + + function initialize() { + p.componentIds = dataSource.getComponents(); + p.contents = dataSource.getContents(); + p.lastCIndex = p.componentIds.length - 1; + } + + + // Adjusts the given locus object to provide the page number within the + // current component. + // + // If the locus implies movement to another component, the locus + // 'componentId' property will be updated to point to this component, and + // the 'load' property will be set to true, which should be taken as a + // sign to call loadPageAt with a callback. + // + // The locus argument is an object that has one of the following properties: + // + // - page: positive integer. Counting up from the start of component. + // - pagesBack: negative integer. Counting back from the end of component. + // - percent: float indicating percentage through the component + // - direction: integer relative to the current page number for this pageDiv + // - position: string, one of "start" or "end", moves to corresponding point + // in the given component + // - anchor: an element id within the component + // - xpath: the node at this XPath within the component + // - selector: the first node at this CSS selector within the component + // + // The locus object can also specify a componentId. If it is not provided + // we default to the currently active component, and if that doesn't exist, + // we default to the very first component. + // + // The locus result will be an object with the following properties: + // + // - load: boolean, true if loading component required, false otherwise + // - componentId: component to load (current componentId if load is false) + // - if load is false: + // - page + // - if load is true: + // - one of page / pagesBack / percent / direction / position / anchor + // + function pageNumberAt(pageDiv, locus) { + locus.load = false; + var currComponent = pageDiv.m.activeFrame ? + pageDiv.m.activeFrame.m.component : + null; + var component = null; + var cIndex = p.componentIds.indexOf(locus.componentId); + if (cIndex < 0 && !currComponent) { + // No specified component, no current component. Load first component. + locus.load = true; + locus.componentId = p.componentIds[0]; + return locus; + } else if ( + cIndex < 0 && + locus.componentId && + currComponent.properties.id != locus.componentId + ) { + // Invalid component, say not found. + pageDiv.m.reader.dispatchEvent( + "monocle:notfound", + { href: locus.componentId } + ); + return null; + } else if (cIndex < 0) { + // No specified (or invalid) component, use current component. + component = currComponent; + locus.componentId = pageDiv.m.activeFrame.m.component.properties.id; + cIndex = p.componentIds.indexOf(locus.componentId); + } else if (!p.components[cIndex] || p.components[cIndex] != currComponent) { + // Specified component differs from current component. Load specified. + locus.load = true; + return locus; + } else { + component = currComponent; + } + + // If we're here, then the locus is based on the current component. + var result = { load: false, componentId: locus.componentId, page: 1 } + + // Get the current last page. + lastPageNum = component.lastPageNumber(); + + // Deduce the page number for the given locus. + if (typeof(locus.page) == "number") { + result.page = locus.page; + } else if (typeof(locus.pagesBack) == "number") { + result.page = lastPageNum + locus.pagesBack; + } else if (typeof(locus.percent) == "number") { + var place = new Monocle.Place(); + place.setPlace(component, 1); + result.page = place.pageAtPercentageThrough(locus.percent); + } else if (typeof(locus.direction) == "number") { + if (!pageDiv.m.place) { + console.warn("Can't move in a direction if pageDiv has no place."); + } + result.page = pageDiv.m.place.pageNumber(); + result.page += locus.direction; + } else if (typeof(locus.anchor) == "string") { + result.page = component.pageForChapter(locus.anchor, pageDiv); + } else if (typeof(locus.xpath) == "string") { + result.page = component.pageForXPath(locus.xpath, pageDiv); + } else if (typeof(locus.selector) == "string") { + result.page = component.pageForSelector(locus.selector, pageDiv); + } else if (typeof(locus.position) == "string") { + if (locus.position == "start") { + result.page = 1; + } else if (locus.position == "end") { + result.page = lastPageNum['new']; + } + } else { + console.warn("Unrecognised locus: " + locus); + } + + if (result.page < 1) { + if (cIndex == 0) { + // On first page of book. + result.page = 1; + result.boundarystart = true; + } else { + // Moving backwards from current component. + result.load = true; + result.componentId = p.componentIds[cIndex - 1]; + result.pagesBack = result.page; + result.page = null; + } + } else if (result.page > lastPageNum) { + if (cIndex == p.lastCIndex) { + // On last page of book. + result.page = lastPageNum; + result.boundaryend = true; + } else { + // Moving forwards from current component. + result.load = true; + result.componentId = p.componentIds[cIndex + 1]; + result.page -= lastPageNum; + } + } + + return result; + } + + + // Same as pageNumberAt, but if a load is not flagged, this will + // automatically update the pageDiv's place to the given pageNumber. + // + // If you call this (ie, from a flipper), you are effectively entering into + // a contract to move the frame offset to the given page returned in the + // locus if load is false. + // + function setPageAt(pageDiv, locus) { + locus = pageNumberAt(pageDiv, locus); + if (locus && !locus.load) { + var evtData = { locus: locus, page: pageDiv } + if (locus.boundarystart) { + pageDiv.m.reader.dispatchEvent('monocle:boundarystart', evtData); + } else if (locus.boundaryend) { + pageDiv.m.reader.dispatchEvent('monocle:boundaryend', evtData); + } else { + var component = p.components[p.componentIds.indexOf(locus.componentId)]; + pageDiv.m.place = pageDiv.m.place || new Monocle.Place(); + pageDiv.m.place.setPlace(component, locus.page); + + var evtData = { + page: pageDiv, + locus: locus, + pageNumber: pageDiv.m.place.pageNumber(), + componentId: locus.componentId + } + pageDiv.m.reader.dispatchEvent("monocle:pagechange", evtData); + } + } + return locus; + } + + + // Will load the given component into the pageDiv's frame, then invoke the + // callback with resulting locus (provided by pageNumberAt). + // + // If the resulting page number is outside the bounds of the new component, + // (ie, pageNumberAt again requests a load), this will recurse into further + // components until non-loading locus is returned by pageNumberAt. Then the + // callback will fire with that locus. + // + // As with setPageAt, if you call this you're obliged to move the frame + // offset to the given page in the locus passed to the callback. + // + function loadPageAt(pageDiv, locus, onLoad, onFail) { + var cIndex = p.componentIds.indexOf(locus.componentId); + if (!locus.load || cIndex < 0) { + locus = pageNumberAt(pageDiv, locus); + } + + if (!locus) { + return onFail ? onFail() : null; + } + + if (!locus.load) { + return onLoad(locus); + } + + var findPageNumber = function () { + locus = setPageAt(pageDiv, locus); + if (!locus) { + return onFail ? onFail() : null; + } else if (locus.load) { + loadPageAt(pageDiv, locus, onLoad, onFail) + } else { + onLoad(locus); + } + } + + var applyComponent = function (component) { + component.applyTo(pageDiv, findPageNumber); + for (var l = 1; l <= p.preloadWindow; ++l) { + deferredPreloadComponent(cIndex+l, l*k.PRELOAD_INTERVAL); + } + } + + loadComponent(cIndex, applyComponent, onFail, pageDiv); + } + + + // If your flipper doesn't care whether a component needs to be + // loaded before the page can be set, you can use this shortcut. + // + function setOrLoadPageAt(pageDiv, locus, onLoad, onFail) { + locus = setPageAt(pageDiv, locus); + if (!locus) { + if (onFail) { onFail(); } + } else if (locus.load) { + loadPageAt(pageDiv, locus, onLoad, onFail); + } else { + onLoad(locus); + } + } + + + // Fetches the component source from the dataSource. + // + // 'index' is the index of the component in the + // dataSource.getComponents array. + // + // 'onLoad' is invoked when the source is received. + // + // 'onFail' is optional, and is invoked if the source could not be fetched. + // + // 'pageDiv' is optional, and simply allows firing events on + // the reader object that has requested this component, ONLY if + // the source has not already been received. + // + function loadComponent(index, onLoad, onFail, pageDiv) { + if (p.components[index]) { + return onLoad(p.components[index]); + } + + var cmptId = p.components[index]; + var evtData = { 'page': pageDiv, 'component': cmptId, 'index': index }; + pageDiv.m.reader.dispatchEvent('monocle:componentloading', evtData); + + var onCmptLoad = function (cmpt) { + evtData['component'] = cmpt; + pageDiv.m.reader.dispatchEvent('monocle:componentloaded', evtData); + onLoad(cmpt); + } + + var onCmptFail = function (cmptId) { + console.warn("Failed to load component: "+cmptId); + pageDiv.m.reader.dispatchEvent('monocle:componentfailed', evtData); + if (onFail) { onFail(); } + } + + _loadComponent(index, onCmptLoad, onCmptFail); + } + + + function preloadComponent(index) { + if (p.components[index]) { return; } + var cmptId = p.componentIds[index]; + if (!cmptId) { return; } + if (p.cmptLoadQueue[cmptId]) { return; } + _loadComponent(index); + } + + + function deferredPreloadComponent(index, delay) { + Monocle.defer(function () { preloadComponent(index); }, delay); + } + + + function _loadComponent(index, successCallback, failureCallback) { + var cmptId = p.componentIds[index]; + var queueItem = { success: successCallback, failure: failureCallback }; + if (p.cmptLoadQueue[cmptId]) { + return p.cmptLoadQueue[cmptId] = queueItem; + } else { + p.cmptLoadQueue[cmptId] = queueItem; + } + + var onCmptFail = function () { + fireLoadQueue(cmptId, 'failure', cmptId); + } + + var onCmptLoad = function (cmptSource) { + if (cmptSource === false) { return onCmptFail(); } + p.components[index] = new Monocle.Component( + API, + cmptId, + index, + chaptersForComponent(cmptId), + cmptSource + ); + fireLoadQueue(cmptId, 'success', p.components[index]); + } + + var cmptSource = p.dataSource.getComponent(cmptId, onCmptLoad); + if (cmptSource && !p.components[index]) { + onCmptLoad(cmptSource); + } else if (cmptSource === false) { + onCmptFail(); + } + } + + + function fireLoadQueue(cmptId, cbName, args) { + if (typeof p.cmptLoadQueue[cmptId][cbName] == 'function') { + p.cmptLoadQueue[cmptId][cbName](args); + } + p.cmptLoadQueue[cmptId] = null; + } + + + // Returns an array of chapter objects that are found in the given component. + // + // A chapter object has this format: + // + // { + // title: "Chapter 1", + // fragment: null + // } + // + // The fragment property of a chapter object is either null (the chapter + // starts at the head of the component) or the fragment part of the URL + // (eg, "foo" in "index.html#foo"). + // + function chaptersForComponent(cmptId) { + if (p.chapters[cmptId]) { + return p.chapters[cmptId]; + } + p.chapters[cmptId] = []; + var matcher = new RegExp('^'+decodeURIComponent(cmptId)+"(\#(.+)|$)"); + var matches; + var recurser = function (chp) { + if (matches = decodeURIComponent(chp.src).match(matcher)) { + p.chapters[cmptId].push({ + title: chp.title, + fragment: matches[2] || null + }); + } + if (chp.children) { + for (var i = 0; i < chp.children.length; ++i) { + recurser(chp.children[i]); + } + } + } + + for (var i = 0; i < p.contents.length; ++i) { + recurser(p.contents[i]); + } + return p.chapters[cmptId]; + } + + + // Returns a locus for the chapter that has the URL given in the + // 'src' argument. + // + // See the comments at pageNumberAt for an explanation of locus objects. + // + function locusOfChapter(src) { + var matcher = new RegExp('^(.+?)(#(.*))?$'); + var matches = src.match(matcher); + if (!matches) { return null; } + var cmptId = componentIdMatching(matches[1]); + if (!cmptId) { return null; } + var locus = { componentId: cmptId } + matches[3] ? locus.anchor = matches[3] : locus.position = "start"; + return locus; + } + + + function isValidLocus(locus) { + if (!locus) { return false; } + if (locus.componentId && !componentIdMatching(locus.componentId)) { + return false; + } + return true; + } + + + function componentIdMatching(str) { + str = decodeURIComponent(str); + for (var i = 0, ii = p.componentIds.length; i < ii; ++i) { + if (decodeURIComponent(p.componentIds[i]) == str) { return str; } + } + return null; + } + + + function componentWeights() { + if (!p.weights) { + p.weights = dataSource.getMetaData('componentWeights') || []; + if (!p.weights.length) { + var cmptSize = 1.0 / p.componentIds.length; + for (var i = 0, ii = p.componentIds.length; i < ii; ++i) { + p.weights.push(cmptSize); + } + } + } + return p.weights; + } + + + API.getMetaData = dataSource.getMetaData; + API.pageNumberAt = pageNumberAt; + API.setPageAt = setPageAt; + API.loadPageAt = loadPageAt; + API.setOrLoadPageAt = setOrLoadPageAt; + API.chaptersForComponent = chaptersForComponent; + API.locusOfChapter = locusOfChapter; + API.isValidLocus = isValidLocus; + API.componentWeights = componentWeights; + + initialize(); + + return API; +} + + +// Legacy function. Deprecated. +// +Monocle.Book.fromNodes = function (nodes) { + console.deprecation("Book.fromNodes() will soon be removed."); + return new Monocle.Book(Monocle.bookDataFromNodes(nodes)); +} + +Monocle.Book.PRELOAD_INTERVAL = 1000; +// PLACE + +Monocle.Place = function () { + + var API = { constructor: Monocle.Place } + var k = API.constants = API.constructor; + var p = API.properties = { + component: null, + percent: null + } + + + function setPlace(cmpt, pageN) { + p.component = cmpt; + p.percent = pageN / cmpt.lastPageNumber(); + p.chapter = null; + } + + + function setPercentageThrough(cmpt, percent) { + p.component = cmpt; + p.percent = percent; + p.chapter = null; + } + + + function componentId() { + return p.component.properties.id; + } + + + // How far we are through the component at the "top of the page". + // + // 0 - start of book. 1.0 - end of book. + // + function percentAtTopOfPage() { + if (k.PAGE_ANCHOR == 'bottom') { + return p.percent - 1.0 / p.component.lastPageNumber(); + } else { + return p.percent; + } + } + + + function percentOnPage() { + return percentAtTopOfPage() + k.PAGE_ANCHOR_OFFSET / pagesInComponent(); + } + + + // How far we are through the component at the "bottom of the page". + // + function percentAtBottomOfPage() { + if (k.PAGE_ANCHOR == 'bottom') { + return p.percent; + } else { + return p.percent + 1.0 / p.component.lastPageNumber(); + } + } + + + // The page number at a given point (0: start, 1: end) within the component. + // + function pageAtPercentageThrough(percent) { + var pages = pagesInComponent(); + if (typeof percent != 'number') { percent = 0; } + return Math.max(Math.round(pages * percent), 1); + } + + + // The page number of this point within the component. + // + function pageNumber() { + return pageAtPercentageThrough(p.percent); + } + + + function pagesInComponent() { + return p.component.lastPageNumber(); + } + + + function chapterInfo() { + if (p.chapter) { + return p.chapter; + } + return p.chapter = p.component.chapterForPage(pageNumber()+1); + } + + + function chapterTitle() { + var chp = chapterInfo(); + return chp ? chp.title : null; + } + + + function chapterSrc() { + var src = componentId(); + var cinfo = chapterInfo(); + if (cinfo && cinfo.fragment) { + src += "#" + cinfo.fragment; + } + return src; + } + + + function getLocus(options) { + options = options || {}; + var locus = { + page: pageNumber(), + componentId: componentId() + } + if (options.direction) { + locus.page += options.direction; + } else { + locus.percent = percentAtBottomOfPage(); + } + return locus; + } + + + // Returns how far this place is in the entire book (0 - start, 1.0 - end). + // + function percentageOfBook() { + var book = p.component.properties.book; + var componentIds = book.properties.componentIds; + var weights = book.componentWeights(); + var cmptIndex = p.component.properties.index; + var pc = weights[cmptIndex] * p.percent; + for (var i = 0, ii = cmptIndex; i < ii; ++i) { pc += weights[i]; } + + // Note: This is a decent estimation of current page number and total + // number of pages, but it's very approximate. Could be improved by storing + // the page counts of all components accessed (since the dimensions of the + // reader last changed), and averaging the result across them. (You + // probably want to ignore calcs for components < 2 or 3 pages long, too. + // The bigger the component, the more accurate the calculation.) + // + // var bkPages = p.component.lastPageNumber() / weights[cmptIndex]; + // console.log('Page: '+ Math.floor(pc*bkPages)+ ' of '+ Math.floor(bkPages)); + + return pc; + } + + + function onFirstPageOfBook() { + return p.component.properties.index == 0 && pageNumber() == 1; + } + + + function onLastPageOfBook() { + return ( + p.component.properties.index == + p.component.properties.book.properties.lastCIndex && + pageNumber() == p.component.lastPageNumber() + ); + } + + + API.setPlace = setPlace; + API.setPercentageThrough = setPercentageThrough; + API.componentId = componentId; + API.percentAtTopOfPage = percentAtTopOfPage; + API.percentOnPage = percentOnPage; + API.percentAtBottomOfPage = percentAtBottomOfPage; + API.pageAtPercentageThrough = pageAtPercentageThrough; + API.pageNumber = pageNumber; + API.pagesInComponent = pagesInComponent; + API.chapterInfo = chapterInfo; + API.chapterTitle = chapterTitle; + API.chapterSrc = chapterSrc; + API.getLocus = getLocus; + API.percentageOfBook = percentageOfBook; + API.onFirstPageOfBook = onFirstPageOfBook; + API.onLastPageOfBook = onLastPageOfBook; + + API.percentageThrough = k.PAGE_ANCHOR == 'bottom' ? percentAtBottomOfPage : + k.PAGE_ANCHOR == 'offset' ? percentOnPage : + percentAtTopOfPage; + + return API; +} + + +// Can set this to 'top', 'offset' or 'bottom'. Old Monocle behaviour is 'bottom'. +// +Monocle.Place.PAGE_ANCHOR = 'offset'; +Monocle.Place.PAGE_ANCHOR_OFFSET = 0.1; + + +Monocle.Place.FromPageNumber = function (component, pageNumber) { + var place = new Monocle.Place(); + place.setPlace(component, pageNumber); + return place; +} + + +Monocle.Place.FromPercentageThrough = function (component, percent) { + var place = new Monocle.Place(); + place.setPercentageThrough(component, percent); + return place; +} + + +// We can't create a place from a percentage of the book, because the +// component may not have been loaded yet. But we can get a locus. +// +Monocle.Place.percentOfBookToLocus = function (reader, percent) { + var book = reader.getBook(); + var componentIds = book.properties.componentIds; + var weights = book.componentWeights(); + var cmptIndex = 0, cmptWeight = 0; + percent = Math.min(percent, 0.99999); + while (percent >= 0) { + cmptWeight = weights[cmptIndex]; + percent -= weights[cmptIndex]; + if (percent >= 0) { + cmptIndex += 1; + if (cmptIndex >= weights.length) { + console.error('Unable to calculate locus from percentage: '+percent); + return; + } + } + } + var cmptPercent = (percent + cmptWeight) / cmptWeight; + return { componentId: componentIds[cmptIndex], percent: cmptPercent } +} +; +/* COMPONENT */ + +// See the properties declaration for details of constructor arguments. +// +Monocle.Component = function (book, id, index, chapters, source) { + + var API = { constructor: Monocle.Component } + var k = API.constants = API.constructor; + var p = API.properties = { + // a back-reference to the public API of the book that owns this component + book: book, + + // the string that represents this component in the book's component array + id: id, + + // the position in the book's components array of this component + index: index, + + // The chapters argument is an array of objects that list the chapters that + // can be found in this component. A chapter object is defined as: + // + // { + // title: str, + // fragment: str, // optional anchor id + // percent: n // how far into the component the chapter begins + // } + // + // NOTE: the percent property is calculated by the component - you only need + // to pass in the title and the optional id string. + // + chapters: chapters, + + // the frame provided by dataSource.getComponent() for this component + source: source + } + + + // Makes this component the active component for the pageDiv. There are + // several strategies for this (see loadFrame). + // + // When the component has been loaded into the pageDiv's frame, the callback + // will be invoked with the pageDiv and this component as arguments. + // + function applyTo(pageDiv, callback) { + prepareSource(pageDiv.m.reader); + + var evtData = { 'page': pageDiv, 'source': p.source }; + pageDiv.m.reader.dispatchEvent('monocle:componentchanging', evtData); + + var onLoaded = function () { + setupFrame( + pageDiv, + pageDiv.m.activeFrame, + function () { callback(pageDiv, API) } + ); + } + + Monocle.defer(function () { loadFrame(pageDiv, onLoaded); }); + } + + + // Loads this component into the given frame, using one of the following + // strategies: + // + // * HTML - a HTML string + // * URL - a URL string + // * Nodes - an array of DOM body nodes (NB: no way to populate head) + // * Document - a DOM DocumentElement object + // + function loadFrame(pageDiv, callback) { + var frame = pageDiv.m.activeFrame; + + // We own this frame now. + frame.m.component = API; + + // Hide the frame while we're changing it. + frame.style.visibility = "hidden"; + + frame.whenDocumentReady = function () { + var doc = frame.contentDocument; + var evtData = { 'page': pageDiv, 'document': doc, 'component': API }; + pageDiv.m.reader.dispatchEvent('monocle:componentmodify', evtData); + frame.whenDocumentReady = null; + } + + if (p.source.html) { + return loadFrameFromHTML(p.source.html || p.source, frame, callback); + } else if (p.source.url) { + return loadFrameFromURL(p.source.url, frame, callback); + } else if (p.source.doc) { + return loadFrameFromDocument(p.source.doc, frame, callback); + } + } + + + // LOAD STRATEGY: HTML + // Loads a HTML string into the given frame, invokes the callback once loaded. + // + function loadFrameFromHTML(src, frame, callback) { + var fn = function () { + Monocle.Events.deafen(frame, 'load', fn); + frame.whenDocumentReady(); + Monocle.defer(callback); + } + Monocle.Events.listen(frame, 'load', fn); + if (Monocle.Browser.env.loadHTMLWithDocWrite) { + frame.contentDocument.open('text/html', 'replace'); + frame.contentDocument.write(src); + frame.contentDocument.close(); + } else { + frame.contentWindow['monCmptData'] = src; + frame.src = "javascript:window['monCmptData'];" + } + } + + + // LOAD STRATEGY: URL + // Loads the URL into the given frame, invokes callback once loaded. + // + function loadFrameFromURL(url, frame, callback) { + // If it's a relative path, we need to make it absolute. + if (!url.match(/^\//)) { + url = absoluteURL(url); + } + var onDocumentReady = function () { + Monocle.Events.deafen(frame, 'load', onDocumentReady); + frame.whenDocumentReady(); + } + var onDocumentLoad = function () { + Monocle.Events.deafen(frame, 'load', onDocumentLoad); + Monocle.defer(callback); + } + Monocle.Events.listen(frame, 'load', onDocumentReady); + Monocle.Events.listen(frame, 'load', onDocumentLoad); + frame.contentWindow.location.replace(url); + } + + + // LOAD STRATEGY: DOCUMENT + // Replaces the DocumentElement of the given frame with the given srcDoc. + // Invokes the callback when loaded. + // + function loadFrameFromDocument(srcDoc, frame, callback) { + var doc = frame.contentDocument; + + // WebKit has an interesting quirk. The tag must exist in the + // document being replaced, not the new document. + if (Monocle.Browser.is.WebKit) { + var srcBase = srcDoc.querySelector('base'); + if (srcBase) { + var head = doc.querySelector('head'); + if (!head) { + try { + head = doc.createElement('head'); + prependChild(doc.documentElement, head); + } catch (e) { + head = doc.body; + } + } + var base = doc.createElement('base'); + base.setAttribute('href', srcBase.href); + head.appendChild(base); + } + } + + doc.replaceChild( + doc.importNode(srcDoc.documentElement, true), + doc.documentElement + ); + + // NB: It's a significant problem with this load strategy that there's + // no indication when it is complete. + Monocle.defer(callback); + } + + + // Once a frame is loaded with this component, call this method to style + // and measure its contents. + // + function setupFrame(pageDiv, frame, callback) { + updateDimensions(pageDiv, function () { + frame.style.visibility = "visible"; + + // Find the place of any chapters in the component. + locateChapters(pageDiv); + + // Nothing can prevent iframe scrolling on Android, so we have to undo it. + if (Monocle.Browser.on.Android) { + Monocle.Events.listen(frame.contentWindow, 'scroll', function () { + frame.contentWindow.scrollTo(0,0); + }); + } + + // Announce that the component has changed. + var doc = frame.contentDocument; + var evtData = { 'page': pageDiv, 'document': doc, 'component': API }; + pageDiv.m.reader.dispatchEvent('monocle:componentchange', evtData); + + callback(); + }); + } + + + // Checks whether the pageDiv dimensions have changed. If they have, + // remeasures dimensions and returns true. Otherwise returns false. + // + function updateDimensions(pageDiv, callback) { + pageDiv.m.dimensions.update(function (pageLength) { + p.pageLength = pageLength; + if (typeof callback == "function") { callback() }; + }); + } + + + // Iterates over all the chapters that are within this component + // (according to the array we were provided on initialization) and finds + // their location (in percentage terms) within the text. + // + // Stores this percentage with the chapter object in the chapters array. + // + function locateChapters(pageDiv) { + if (p.chapters[0] && typeof p.chapters[0].percent == "number") { + return; + } + var doc = pageDiv.m.activeFrame.contentDocument; + for (var i = 0; i < p.chapters.length; ++i) { + var chp = p.chapters[i]; + chp.percent = 0; + if (chp.fragment) { + var node = doc.getElementById(chp.fragment); + chp.percent = pageDiv.m.dimensions.percentageThroughOfNode(node); + } + } + return p.chapters; + } + + + // For a given page number within the component, return the chapter that + // starts on or most-recently-before this page. + // + // Useful, for example, in displaying the current chapter title as a + // running head on the page. + // + function chapterForPage(pageN) { + var cand = null; + var percent = (pageN - 1) / p.pageLength; + for (var i = 0; i < p.chapters.length; ++i) { + if (percent >= p.chapters[i].percent) { + cand = p.chapters[i]; + } else { + return cand; + } + } + return cand; + } + + + // For a given chapter fragment (the bit after the hash + // in eg, "index.html#foo"), return the page number on which + // the chapter starts. If the fragment is null or blank, will + // return the first page of the component. + // + function pageForChapter(fragment, pageDiv) { + if (!fragment) { + return 1; + } + for (var i = 0; i < p.chapters.length; ++i) { + if (p.chapters[i].fragment == fragment) { + return percentToPageNumber(p.chapters[i].percent); + } + } + var doc = pageDiv.m.activeFrame.contentDocument; + var node = doc.getElementById(fragment); + var percent = pageDiv.m.dimensions.percentageThroughOfNode(node); + return percentToPageNumber(percent); + } + + + function pageForXPath(xpath, pageDiv) { + var doc = pageDiv.m.activeFrame.contentDocument; + var percent = 0; + if (Monocle.Browser.env.supportsXPath) { + var node = doc.evaluate(xpath, doc, null, 9, null).singleNodeValue; + if (node) { + percent = pageDiv.m.dimensions.percentageThroughOfNode(node); + } + } else { + console.warn("XPath not supported in this client."); + } + return percentToPageNumber(percent); + } + + + function pageForSelector(selector, pageDiv) { + var doc = pageDiv.m.activeFrame.contentDocument; + var percent = 0; + if (Monocle.Browser.env.supportsQuerySelector) { + var node = doc.querySelector(selector); + if (node) { + percent = pageDiv.m.dimensions.percentageThroughOfNode(node); + } + } else { + console.warn("querySelector not supported in this client."); + } + return percentToPageNumber(percent); + } + + + function percentToPageNumber(pc) { + return Math.floor(pc * p.pageLength) + 1; + } + + + // A public getter for p.pageLength. + // + function lastPageNumber() { + return p.pageLength; + } + + + function prepareSource(reader) { + if (p.sourcePrepared) { return; } + p.sourcePrepared = true; + + if (typeof p.source == "string") { + p.source = { html: p.source }; + } + + // If supplied as escaped javascript, unescape it to HTML by evalling it. + if (p.source.javascript) { + console.deprecation( + "Loading a component by 'javascript' is deprecated. " + + "Use { 'html': src } -- no need to escape or clean the string." + ); + var src = p.source.javascript; + src = src.replace(/\\n/g, "\n"); + src = src.replace(/\\r/g, "\r"); + src = src.replace(/\\'/g, "'"); + p.source = { html: src }; + } + + // If supplied as DOM nodes, convert to HTML by concatenating outerHTMLs. + if (p.source.nodes) { + var srcs = []; + for (var i = 0, ii = p.source.nodes.length; i < ii; ++i) { + var node = p.source.nodes[i]; + if (node.outerHTML) { + srcs.push(node.outerHTML); + } else { + var div = document.createElement('div'); + div.appendChild(node.cloneNode(true)); + srcs.push(div.innerHTML); + delete(div); + } + } + p.source = { html: srcs.join('') }; + } + + if (p.source.html && !p.source.html.match(new RegExp("", "im"))) { + var baseURI = computeBaseURI(reader); + if (baseURI) { + p.source.html = p.source.html.replace( + new RegExp("(]*>)", "im"), + '$1' + ); + } + } + + if (p.source.doc && !p.source.doc.querySelector('base')) { + var srcHead = p.source.doc.querySelector('head') || p.source.doc.body; + var baseURI = computeBaseURI(reader); + if (srcHead && baseURI) { + var srcBase = p.source.doc.createElement('base'); + srcBase.setAttribute('href', baseURI); + prependChild(srcHead, srcBase); + } + } + } + + + function computeBaseURI(reader) { + var evtData = { cmptId: p.id, cmptURI: absoluteURL(p.id) } + if (reader.dispatchEvent('monocle:component:baseuri', evtData, true)) { + return evtData.cmptURI; + } + } + + + function absoluteURL(url) { + var link = document.createElement('a'); + link.setAttribute('href', url); + result = link.href; + delete(link); + return result; + } + + + function prependChild(pr, el) { + pr.firstChild ? pr.insertBefore(el, pr.firstChild) : pr.appendChild(el); + } + + + API.applyTo = applyTo; + API.updateDimensions = updateDimensions; + API.chapterForPage = chapterForPage; + API.pageForChapter = pageForChapter; + API.pageForXPath = pageForXPath; + API.pageForSelector = pageForSelector; + API.lastPageNumber = lastPageNumber; + + return API; +} +; +Monocle.Selection = function (reader) { + var API = { constructor: Monocle.Selection }; + var k = API.constants = API.constructor; + var p = API.properties = { + reader: reader, + lastSelection: [] + }; + + + function initialize() { + if (k.SELECTION_POLLING_INTERVAL) { + setInterval(pollSelection, k.SELECTION_POLLING_INTERVAL); + } + } + + + function pollSelection() { + var index = 0, frame = null; + while (frame = reader.dom.find('component', index++)) { + if (frame.contentWindow) { + pollSelectionOnWindow(frame.contentWindow, index); + } + } + } + + + function pollSelectionOnWindow(win, index) { + var sel = win.getSelection(); + var lm = p.lastSelection[index] || {}; + var nm = p.lastSelection[index] = { + selected: anythingSelected(win), + range: sel.rangeCount ? sel.getRangeAt(0) : null, + string: sel.toString() + }; + if (nm.selected) { + nm.rangeStartContainer = nm.range.startContainer; + nm.rangeEndContainer = nm.range.endContainer; + nm.rangeStartOffset = nm.range.startOffset; + nm.rangeEndOffset = nm.range.endOffset; + if (!sameRange(nm, lm)) { + p.reader.dispatchEvent('monocle:selection', nm); + } + } else if (lm.selected) { + p.reader.dispatchEvent('monocle:deselection', lm); + } + } + + + function sameRange(m1, m2) { + return ( + m1.rangeStartContainer == m2.rangeStartContainer && + m1.rangeEndContainer == m2.rangeEndContainer && + m1.rangeStartOffset == m2.rangeStartOffset && + m1.rangeEndOffset == m2.rangeEndOffset + ); + } + + + // Given a window object, remove any user selections within. Trivial in + // most browsers, but involving major mojo on iOS. + // + function deselect() { + var index = 0, frame = null; + while (frame = reader.dom.find('component', index++)) { + deselectOnWindow(frame.contentWindow); + } + } + + + function deselectOnWindow(win) { + win = win || window; + if (!anythingSelected(win)) { return; } + + if (Monocle.Browser.iOSVersion && !Monocle.Browser.iOSVersionBelow(5)) { + preservingScale(function () { + preservingScrollPosition(function () { + var inp = document.createElement('input'); + inp.style.cssText = [ + 'position: absolute', + 'top: 0', + 'left: 0', + 'width: 0', + 'height: 0' + ].join(';'); + document.body.appendChild(inp); + inp.focus(); + document.body.removeChild(inp); + }) + }); + } + + var sel = win.getSelection(); + sel.removeAllRanges(); + win.document.body.scrollLeft = 0; + win.document.body.scrollTop = 0; + } + + + function preservingScrollPosition(fn) { + var sx = window.scrollX, sy = window.scrollY; + fn(); + window.scrollTo(sx, sy); + } + + + function preservingScale(fn) { + var head = document.querySelector('head'); + var ovp = head.querySelector('meta[name=viewport]'); + var createViewportMeta = function (content) { + var elem = document.createElement('meta'); + elem.setAttribute('name', 'viewport'); + elem.setAttribute('content', content); + head.appendChild(elem); + return elem; + } + + if (ovp) { + var ovpcontent = ovp.getAttribute('content'); + var re = /user-scalable\s*=\s*([^,$\s])*/; + var result = ovpcontent.match(re); + if (result && ['no', '0'].indexOf(result[1]) >= 0) { + fn(); + } else { + var nvpcontent = ovpcontent.replace(re, ''); + nvpcontent += nvpcontent ? ', ' : ''; + nvpcontent += 'user-scalable=no'; + head.removeChild(ovp); + var nvp = createViewportMeta(nvpcontent); + fn(); + head.removeChild(nvp); + head.appendChild(ovp); + } + } else { + var nvp = createViewportMeta('user-scalable=no'); + fn(); + nvp.setAttribute('content', 'user-scalable=yes'); + } + } + + + function anythingSelected(win) { + return !win.getSelection().isCollapsed; + } + + + API.deselect = deselect; + + + initialize(); + + return API; +} + + +Monocle.Selection.SELECTION_POLLING_INTERVAL = 250; +Monocle.Billboard = function (reader) { + var API = { constructor: Monocle.Billboard }; + var k = API.constants = API.constructor; + var p = API.properties = { + reader: reader, + cntr: null + }; + + + function show(urlOrElement, options) { + p.reader.dispatchEvent('monocle:modal:on'); + if (p.cntr) { return console.warn("Modal billboard already showing."); } + + var options = options || {}; + var elem = urlOrElement; + p.cntr = reader.dom.append('div', k.CLS.cntr); + if (typeof urlOrElement == 'string') { + var url = urlOrElement; + p.inner = elem = p.cntr.dom.append('iframe', k.CLS.inner); + elem.setAttribute('src', url); + } else { + p.inner = p.cntr.dom.append('div', k.CLS.inner); + p.inner.appendChild(elem); + } + p.dims = [ + elem.naturalWidth || elem.offsetWidth, + elem.naturalHeight || elem.offsetHeight + ]; + if (options.closeButton != false) { + var cBtn = p.cntr.dom.append('div', k.CLS.closeButton); + Monocle.Events.listenForTap(cBtn, hide); + } + align(options.align || 'left top'); + p.reader.listen('monocle:resize', align); + + shrink(options.from); + Monocle.defer(grow); + } + + + function hide(evt) { + shrink(); + Monocle.Events.afterTransition(p.cntr, remove); + } + + + function grow() { + Monocle.Styles.transitionFor(p.cntr, 'transform', k.ANIM_MS, 'ease-in-out'); + Monocle.Styles.affix(p.cntr, 'transform', 'translate(0, 0) scale(1)'); + } + + + function shrink(from) { + p.from = from || p.from || [0,0]; + var translate = 'translate('+p.from[0]+'px, '+p.from[1]+'px)'; + var scale = 'scale(0)'; + if (typeof p.from[2] === 'number') { + scale = 'scaleX('+(p.from[2] / p.cntr.offsetWidth)+') '; + scale += 'scaleY('+(p.from[3] / p.cntr.offsetHeight)+')'; + } + Monocle.Styles.affix(p.cntr, 'transform', translate+' '+scale); + } + + + function remove () { + p.cntr.parentNode.removeChild(p.cntr); + p.cntr = p.inner = null; + p.reader.deafen('monocle:resize', align); + p.reader.dispatchEvent('monocle:modal:off'); + } + + + function align(loc) { + p.alignment = (typeof loc == 'string') ? loc : p.alignment; + if (!p.alignment) { return; } + if (p.dims[0] > p.inner.offsetWidth || p.dims[1] > p.inner.offsetHeight) { + p.cntr.dom.addClass(k.CLS.oversized); + } else { + p.cntr.dom.removeClass(k.CLS.oversized); + } + + var s = p.alignment.split(/\s+/); + var l = 0, t = 0; + var w = (p.inner.scrollWidth - p.inner.offsetWidth); + var h = (p.inner.scrollHeight - p.inner.offsetHeight); + if (s[0].match(/^\d+$/)) { + l = Math.max(0, parseInt(s[0]) - (p.inner.offsetWidth / 2)); + } else if (s[0] == 'center') { + l = w / 2; + } else if (s[0] == 'right') { + l = w; + } + if (s[1] && s[1].match(/^\d+$/)) { + t = Math.max(0, parseInt(s[1]) - (p.inner.offsetHeight / 2)); + } else if (!s[1] || s[1] == 'center') { + t = h / 2; + } else if (s[1] == 'bottom') { + t = h; + } + p.inner.scrollLeft = l; + p.inner.scrollTop = t; + } + + + API.show = show; + API.hide = hide; + API.align= align; + + return API; +} + + +Monocle.Billboard.CLS = { + cntr: 'billboard_container', + inner: 'billboard_inner', + closeButton: 'billboard_close', + oversized: 'billboard_oversized' +} + +Monocle.Billboard.ANIM_MS = 400; +// A panel is an invisible column of interactivity. When contact occurs +// (mousedown, touchstart), the panel expands to the full width of its +// container, to catch all interaction events and prevent them from hitting +// other things. +// +// Panels are used primarily to provide hit zones for page flipping +// interactions, but you can do whatever you like with them. +// +// After instantiating a panel and adding it to the reader as a control, +// you can call listenTo() with a hash of methods for any of 'start', 'move' +// 'end' and 'cancel'. +// +Monocle.Controls.Panel = function () { + + var API = { constructor: Monocle.Controls.Panel } + var k = API.constants = API.constructor; + var p = API.properties = { + evtCallbacks: {} + } + + function createControlElements(cntr) { + p.div = cntr.dom.make('div', k.CLS.panel); + p.div.dom.setStyles(k.DEFAULT_STYLES); + Monocle.Events.listenForContact( + p.div, + { + 'start': start, + 'move': move, + 'end': end, + 'cancel': cancel + }, + { useCapture: false } + ); + return p.div; + } + + + function setDirection(dir) { + p.direction = dir; + } + + + function listenTo(evtCallbacks) { + p.evtCallbacks = evtCallbacks; + } + + + function deafen() { + p.evtCallbacks = {} + } + + + function start(evt) { + p.contact = true; + evt.m.offsetX += p.div.offsetLeft; + evt.m.offsetY += p.div.offsetTop; + expand(); + invoke('start', evt); + } + + + function move(evt) { + if (!p.contact) { + return; + } + invoke('move', evt); + } + + + function end(evt) { + if (!p.contact) { + return; + } + Monocle.Events.deafenForContact(p.div, p.listeners); + contract(); + p.contact = false; + invoke('end', evt); + } + + + function cancel(evt) { + if (!p.contact) { + return; + } + Monocle.Events.deafenForContact(p.div, p.listeners); + contract(); + p.contact = false; + invoke('cancel', evt); + } + + + function invoke(evtType, evt) { + if (p.evtCallbacks[evtType]) { + p.evtCallbacks[evtType](p.direction, evt.m.offsetX, evt.m.offsetY, API); + } + evt.preventDefault(); + } + + + function expand() { + if (p.expanded) { + return; + } + p.div.dom.addClass(k.CLS.expanded); + p.expanded = true; + } + + + function contract(evt) { + if (!p.expanded) { + return; + } + p.div.dom.removeClass(k.CLS.expanded); + p.expanded = false; + } + + + API.createControlElements = createControlElements; + API.listenTo = listenTo; + API.deafen = deafen; + API.expand = expand; + API.contract = contract; + API.setDirection = setDirection; + + return API; +} + + +Monocle.Controls.Panel.CLS = { + panel: 'panel', + expanded: 'controls_panel_expanded' +} +Monocle.Controls.Panel.DEFAULT_STYLES = { + position: 'absolute', + height: '100%' +} +; +// The simplest page-flipping interaction system: contact to the left half of +// the reader turns back one page, contact to the right half turns forward +// one page. +// +Monocle.Panels.TwoPane = function (flipper, evtCallbacks) { + + var API = { constructor: Monocle.Panels.TwoPane } + var k = API.constants = API.constructor; + var p = API.properties = {} + + + function initialize() { + p.panels = { + forwards: new Monocle.Controls.Panel(), + backwards: new Monocle.Controls.Panel() + } + + for (dir in p.panels) { + flipper.properties.reader.addControl(p.panels[dir]); + p.panels[dir].listenTo(evtCallbacks); + p.panels[dir].setDirection(flipper.constants[dir.toUpperCase()]); + var style = { "width": k.WIDTH }; + style[(dir == "forwards" ? "right" : "left")] = 0; + p.panels[dir].properties.div.dom.setStyles(style); + } + } + + + initialize(); + + return API; +} + +Monocle.Panels.TwoPane.WIDTH = "50%"; +// A three-pane system of page interaction. The left 33% turns backwards, the +// right 33% turns forwards, and contact on the middle third causes the +// system to go into "interactive mode". In this mode, the page-flipping panels +// are only active in the margins, and all of the actual text content of the +// book is selectable. The user can exit "interactive mode" by hitting the little +// IMode icon in the lower right corner of the reader. +// +Monocle.Panels.IMode = function (flipper, evtCallbacks) { + + var API = { constructor: Monocle.Panels.IMode } + var k = API.constants = API.constructor; + var p = API.properties = {} + + + function initialize() { + p.flipper = flipper; + p.reader = flipper.properties.reader; + p.panels = { + forwards: new Monocle.Controls.Panel(), + backwards: new Monocle.Controls.Panel() + } + p.divs = {} + + for (dir in p.panels) { + p.reader.addControl(p.panels[dir]); + p.divs[dir] = p.panels[dir].properties.div; + p.panels[dir].listenTo(evtCallbacks); + p.panels[dir].setDirection(flipper.constants[dir.toUpperCase()]); + p.divs[dir].style.width = "33%"; + p.divs[dir].style[dir == "forwards" ? "right" : "left"] = 0; + } + + p.panels.central = new Monocle.Controls.Panel(); + p.reader.addControl(p.panels.central); + p.divs.central = p.panels.central.properties.div; + p.divs.central.dom.setStyles({ left: "33%", width: "34%" }); + menuCallbacks({ end: modeOn }); + + for (dir in p.panels) { + p.divs[dir].dom.addClass('panels_imode_panel'); + p.divs[dir].dom.addClass('panels_imode_'+dir+'Panel'); + } + + p.toggleIcon = { + createControlElements: function (cntr) { + var div = cntr.dom.make('div', 'panels_imode_toggleIcon'); + Monocle.Events.listenForTap(div, modeOff); + return div; + } + } + p.reader.addControl(p.toggleIcon, null, { hidden: true }); + } + + + function menuCallbacks(callbacks) { + p.menuCallbacks = callbacks; + p.panels.central.listenTo(p.menuCallbacks); + } + + + function toggle() { + p.interactive ? modeOff() : modeOn(); + } + + + function modeOn() { + if (p.interactive) { + return; + } + + p.panels.central.contract(); + + var page = p.reader.visiblePages()[0]; + var sheaf = page.m.sheafDiv; + var bw = sheaf.offsetLeft; + var fw = page.offsetWidth - (sheaf.offsetLeft + sheaf.offsetWidth); + bw = Math.floor(((bw - 2) / page.offsetWidth) * 10000 / 100 ) + "%"; + fw = Math.floor(((fw - 2) / page.offsetWidth) * 10000 / 100 ) + "%"; + + startCameo(function () { + p.divs.forwards.style.width = fw; + p.divs.backwards.style.width = bw; + Monocle.Styles.affix(p.divs.central, 'transform', 'translateY(-100%)'); + }); + + p.reader.showControl(p.toggleIcon); + + p.interactive = true; + } + + + function modeOff() { + if (!p.interactive) { + return; + } + + p.panels.central.contract(); + + p.reader.selection.deselect(); + + startCameo(function () { + p.divs.forwards.style.width = "33%"; + p.divs.backwards.style.width = "33%"; + Monocle.Styles.affix(p.divs.central, 'transform', 'translateY(0)'); + }); + + p.reader.hideControl(p.toggleIcon); + + p.interactive = false; + } + + + function startCameo(fn) { + // Set transitions on the panels. + var trn = Monocle.Panels.IMode.CAMEO_DURATION+"ms ease-in"; + Monocle.Styles.affix(p.divs.forwards, 'transition', "width "+trn); + Monocle.Styles.affix(p.divs.backwards, 'transition', "width "+trn); + Monocle.Styles.affix(p.divs.central, 'transition', "-webkit-transform "+trn); + + // Temporarily disable listeners. + for (var pan in p.panels) { + p.panels[pan].deafen(); + } + + // Set the panels to opaque. + for (var div in p.divs) { + p.divs[div].style.opacity = 1; + } + + if (typeof WebkitTransitionEvent != "undefined") { + p.cameoListener = Monocle.Events.listen( + p.divs.central, + 'webkitTransitionEnd', + endCameo + ); + } else { + setTimeout(endCameo, k.CAMEO_DURATION); + } + fn(); + } + + + function endCameo() { + setTimeout(function () { + // Remove panel transitions. + var trn = "opacity linear " + Monocle.Panels.IMode.LINGER_DURATION + "ms"; + Monocle.Styles.affix(p.divs.forwards, 'transition', trn); + Monocle.Styles.affix(p.divs.backwards, 'transition', trn); + Monocle.Styles.affix(p.divs.central, 'transition', trn); + + // Set the panels to transparent. + for (var div in p.divs) { + p.divs[div].style.opacity = 0; + } + + // Re-enable listeners. + p.panels.forwards.listenTo(evtCallbacks); + p.panels.backwards.listenTo(evtCallbacks); + p.panels.central.listenTo(p.menuCallbacks); + }, Monocle.Panels.IMode.LINGER_DURATION); + + + if (p.cameoListener) { + Monocle.Events.deafen(p.divs.central, 'webkitTransitionEnd', endCameo); + } + } + + + API.toggle = toggle; + API.modeOn = modeOn; + API.modeOff = modeOff; + API.menuCallbacks = menuCallbacks; + + initialize(); + + return API; +} + +Monocle.Panels.IMode.CAMEO_DURATION = 250; +Monocle.Panels.IMode.LINGER_DURATION = 250; +Monocle.Panels.eInk = function (flipper, evtCallbacks) { + + var API = { constructor: Monocle.Panels.eInk } + var k = API.constants = API.constructor; + var p = API.properties = { + flipper: flipper + } + + + function initialize() { + p.panel = new Monocle.Controls.Panel(); + p.reader = p.flipper.properties.reader; + p.reader.addControl(p.panel); + + p.panel.listenTo({ end: function (panel, x) { + if (x < p.panel.properties.div.offsetWidth / 2) { + p.panel.setDirection(flipper.constants.BACKWARDS); + } else { + p.panel.setDirection(flipper.constants.FORWARDS); + } + evtCallbacks.end(panel, x); + } }); + + var s = p.panel.properties.div.style; + p.reader.listen("monocle:componentchanging", function () { + s.opacity = 1; + Monocle.defer(function () { s.opacity = 0 }, 40); + }); + s.width = "100%"; + s.background = "#000"; + s.opacity = 0; + + if (k.LISTEN_FOR_KEYS) { + Monocle.Events.listen(window.top.document, 'keyup', handleKeyEvent); + } + } + + + function handleKeyEvent(evt) { + var eventCharCode = evt.charCode || evt.keyCode; + var dir = null; + if (eventCharCode == k.KEYS["PAGEUP"]) { + dir = flipper.constants.BACKWARDS; + } else if (eventCharCode == k.KEYS["PAGEDOWN"]) { + dir = flipper.constants.FORWARDS; + } + if (dir) { + flipper.moveTo({ direction: dir }); + evt.preventDefault(); + } + } + + + initialize(); + + return API; +} + + +Monocle.Panels.eInk.LISTEN_FOR_KEYS = true; +Monocle.Panels.eInk.KEYS = { "PAGEUP": 33, "PAGEDOWN": 34 }; +// Provides page-flipping panels only in the margins of the book. This is not +// entirely suited to small screens with razor-thin margins, but is an +// appropriate panel class for larger screens (like, say, an iPad). +// +// Since the flipper hit zones are only in the margins, the actual text content +// of the book is always selectable. +// +Monocle.Panels.Marginal = function (flipper, evtCallbacks) { + + var API = { constructor: Monocle.Panels.Marginal } + var k = API.constants = API.constructor; + var p = API.properties = {} + + + function initialize() { + p.panels = { + forwards: new Monocle.Controls.Panel(), + backwards: new Monocle.Controls.Panel() + } + + for (dir in p.panels) { + flipper.properties.reader.addControl(p.panels[dir]); + p.panels[dir].listenTo(evtCallbacks); + p.panels[dir].setDirection(flipper.constants[dir.toUpperCase()]); + with (p.panels[dir].properties.div.style) { + dir == "forwards" ? right = 0 : left = 0; + } + } + setWidths(); + } + + + function setWidths() { + var page = flipper.properties.reader.dom.find('page'); + var sheaf = page.m.sheafDiv; + var bw = sheaf.offsetLeft; + var fw = page.offsetWidth - (sheaf.offsetLeft + sheaf.offsetWidth); + bw = Math.floor(((bw - 2) / page.offsetWidth) * 10000 / 100) + "%"; + fw = Math.floor(((fw - 2) / page.offsetWidth) * 10000 / 100) + "%"; + p.panels.forwards.properties.div.style.width = fw; + p.panels.backwards.properties.div.style.width = bw; + } + + + API.setWidths = setWidths; + + initialize(); + + return API; +} +; +Monocle.Panels.Magic = function (flipper, evtCallbacks) { + + var API = { constructor: Monocle.Panels.Magic } + var k = API.constants = API.constructor; + var p = API.properties = { + flipper: flipper, + evtCallbacks: evtCallbacks, + parts: {}, + action: {}, + contacts: [], + startListeners: [], + disabled: false + } + + + function initialize() { + p.reader = flipper.properties.reader; + p.parts = { + reader: p.reader.dom.find('box'), + cmpts: [] + } + for (var i = 0; i < p.flipper.pageCount; ++i) { + p.parts.cmpts.push(p.reader.dom.find('component', i)); + } + initListeners(); + + p.reader.listen('monocle:componentmodify', initListeners); + p.reader.listen('monocle:magic:init', initListeners); + p.reader.listen('monocle:magic:halt', haltListeners); + p.reader.listen('monocle:modal:on', disable); + p.reader.listen('monocle:modal:off', enable); + Monocle.Events.listen(window, 'gala:contact:cancel', resetAction); + } + + + function initListeners(evt) { + //console.log('magic:init'); + stopListening(); + startListening(); + } + + + function haltListeners(evt) { + //console.log('magic:halt'); + stopListening(); + } + + + function disable(evt) { + //console.log('modal:on - halting magic'); + stopListening(); + p.disabled = true; + } + + + function enable(evt) { + //console.log('modal:off - initing magic'); + p.disabled = false; + startListening(); + } + + + function startListening() { + if (p.disabled || p.startListeners.length) { return; } + + p.startListeners.push([ + p.parts.reader, + Monocle.Events.listenForContact( + p.parts.reader, + { 'start': translatorFunction(p.parts.reader, readerContactStart) } + ) + ]); + + for (var i = 0, ii = p.parts.cmpts.length; i < ii; ++i) { + p.startListeners.push([ + p.parts.cmpts[i].contentDocument.defaultView, + Monocle.Events.listenForContact( + p.parts.cmpts[i].contentDocument.defaultView, + { 'start': translatorFunction(p.parts.cmpts[i], cmptContactStart) } + ) + ]); + } + } + + + function stopListening() { + if (p.disabled || !p.startListeners.length) { return; } + for (var j = 0, jj = p.startListeners.length; j < jj; ++j) { + Monocle.Events.deafenForContact( + p.startListeners[j][0], + p.startListeners[j][1] + ); + } + p.startListeners = []; + } + + + function listenForMoveAndEnd(fnMove, fnEnd) { + listenOnElem( + document.defaultView, + translatorFunction(document.documentElement, fnMove), + translatorFunction(document.documentElement, fnEnd) + ); + for (var i = 0, ii = p.parts.cmpts.length; i < ii; ++i) { + listenOnElem( + p.parts.cmpts[i].contentDocument.defaultView, + translatorFunction(p.parts.cmpts[i], fnMove), + translatorFunction(p.parts.cmpts[i], fnEnd) + ); + } + } + + + function listenOnElem(elem, fnMove, fnEnd) { + var contactListeners = Monocle.Events.listenForContact( + elem, + { + 'move': fnMove, + 'end': function (evt) { deafenContactListeners(); fnEnd(evt); } + } + ); + p.contacts.push([elem, contactListeners]); + } + + + function deafenContactListeners() { + for (var i = 0, ii = p.contacts.length; i < ii; ++i) { + Monocle.Events.deafenForContact(p.contacts[i][0], p.contacts[i][1]); + } + p.contacts = []; + } + + + function readerContactStart(evt) { + listenForMoveAndEnd(readerContactMove, readerContactEnd); + p.action.startX = evt.m.readerX; + p.action.startY = evt.m.readerY; + p.action.screenX = evt.m.screenX; + p.action.screenY = evt.m.screenY; + p.action.dir = evt.m.readerX > halfway() ? k.FORWARDS : k.BACKWARDS; + p.action.handled = !dispatch('monocle:magic:contact:start', evt); + if (!p.action.handled) { invoke('start', evt); } + } + + + function readerContactMove(evt) { + if (p.action.handled) { + dispatch('monocle:magic:contact:move', evt); + } else { + invoke('move', evt); + } + // Can't prevent mousemove, so has no effect there. Preventing default + // for touchmove will override scrolling, while still allowing selection. + evt.preventDefault(); + } + + + function readerContactEnd(evt) { + p.action.endX = evt.m.readerX; + p.action.endY = evt.m.readerY; + if (dispatch('monocle:magic:contact', evt)) { invoke('end', evt); } + p.action = {}; + } + + + function cmptContactStart(evt) { + if (actionIsCancelled(evt)) { return resetAction(); } + p.action.startX = evt.m.readerX; + p.action.startY = evt.m.readerY; + p.action.screenX = evt.m.screenX; + p.action.screenY = evt.m.screenY; + listenForMoveAndEnd(cmptContactMove, cmptContactEnd); + } + + + function cmptContactMove(evt) { + if (actionIsEmpty()) { return; } + if (actionIsCancelled(evt)) { return resetAction(); } + + // Can't prevent mousemove, so has no effect there. Preventing default + // for touchmove will override scrolling, while still allowing selection. + evt.preventDefault(); + } + + + function cmptContactEnd(evt) { + if (actionIsEmpty()) { return; } + if (actionIsCancelled(evt)) { return resetAction(); } + p.action.endX = evt.m.readerX; + p.action.endY = evt.m.readerY; + if (Math.abs(p.action.endX - p.action.startX) < k.LEEWAY) { + p.action.dir = p.action.startX > halfway() ? k.FORWARDS : k.BACKWARDS; + } else { + p.action.dir = p.action.startX > p.action.endX ? k.FORWARDS : k.BACKWARDS; + } + + if (dispatch('monocle:magic:contact', evt)) { + invoke('start', evt); + invoke('end', evt); + } + p.action = {}; + } + + + // Adds two new properties to evt.m: + // - readerX + // - readerY + // + // Calculated as the offset of the click from the top left of reader element. + // + // Then calls the passed function. + // + function translatorFunction(registrant, callback) { + return function (evt) { + translatingReaderOffset(registrant, evt, callback); + } + } + + + function translatingReaderOffset(registrant, evt, callback) { + if (typeof p.action.screenX != 'undefined') { + evt.m.readerX = p.action.startX + (evt.m.screenX - p.action.screenX); + evt.m.readerY = p.action.startY + (evt.m.screenY - p.action.screenY); + } else { + var dr = document.documentElement.getBoundingClientRect(); + var rr = p.parts.reader.getBoundingClientRect(); + rr = { left: rr.left - dr.left, top: rr.top - dr.top } + + if (evt.view == window) { + evt.m.readerX = Math.round(evt.m.pageX - rr.left); + evt.m.readerY = Math.round(evt.m.pageY - rr.top); + } else { + var er = registrant.getBoundingClientRect(); + er = { left: er.left - dr.left, top: er.top - dr.top } + evt.m.readerX = Math.round((er.left - rr.left) + evt.m.clientX); + evt.m.readerY = Math.round((er.top - rr.top) + evt.m.clientY); + } + } + + callback(evt); + } + + + function halfway() { + return p.parts.reader.offsetWidth / 2; + } + + + function resetAction() { + p.action = {}; + deafenContactListeners(); + } + + + function actionIsCancelled(evt) { + var win = evt.target.ownerDocument.defaultView; + return (evt.defaultPrevented || !win.getSelection().isCollapsed); + } + + + function actionIsEmpty() { + return typeof p.action.startX == 'undefined'; + } + + + // Returns true if the event WAS NOT cancelled. + function dispatch(evtName, trigger) { + var rr = p.parts.reader.getBoundingClientRect(); + var evtData = { + trigger: trigger, + start: { x: p.action.startX, y: p.action.startY }, + end: { x: p.action.endX, y: p.action.endY }, + max: { x: rr.right - rr.left, y: rr.bottom - rr.top } + } + return p.reader.dispatchEvent(evtName, evtData, true); + } + + + function invoke(evtType, evt) { + if (p.evtCallbacks[evtType]) { + p.evtCallbacks[evtType](p.action.dir, evt.m.readerX, evt.m.readerY, API); + } + } + + + API.enable = enable; + API.disable = disable; + + initialize(); + + return API; +} + + +Monocle.Panels.Magic.LEEWAY = 3; +Monocle.Panels.Magic.FORWARDS = 1; +Monocle.Panels.Magic.BACKWARDS = -1; +Monocle.Dimensions.Columns = function (pageDiv) { + + var API = { constructor: Monocle.Dimensions.Columns } + var k = API.constants = API.constructor; + var p = API.properties = { + page: pageDiv, + reader: pageDiv.m.reader, + length: 0, + width: 0 + } + + // Logically, forceColumn browsers can't have a gap, because that would + // make the minWidth > 200%. But how much greater? Not worth the effort. + k.GAP = Monocle.Browser.env.forceColumns ? 0 : 20; + + function update(callback) { + setColumnWidth(); + Monocle.defer(function () { + p.length = columnCount(); + if (Monocle.DEBUG) { + console.log( + 'page['+p.page.m.pageIndex+'] -> '+p.length+ + ' ('+p.page.m.activeFrame.m.component.properties.id+')' + ); + } + callback(p.length); + }); + } + + + function setColumnWidth() { + var pdims = pageDimensions(); + var ce = columnedElement(); + + p.width = pdims.width; + + var rules = Monocle.Styles.rulesToString(k.STYLE["columned"]); + rules += Monocle.Browser.css.toCSSDeclaration('column-width', pdims.col+'px'); + rules += Monocle.Browser.css.toCSSDeclaration('column-gap', k.GAP+'px'); + rules += Monocle.Browser.css.toCSSDeclaration('column-fill', 'auto'); + rules += Monocle.Browser.css.toCSSDeclaration('transform', 'none'); + + if (Monocle.Browser.env.forceColumns && ce.scrollHeight > pdims.height) { + rules += Monocle.Styles.rulesToString(k.STYLE['column-force']); + if (Monocle.DEBUG) { + console.warn("Force columns ("+ce.scrollHeight+" > "+pdims.height+")"); + } + } + + if (ce.style.cssText != rules) { + // Update offset because we're translating to zero. + p.page.m.offset = 0; + + // IE10 hack. + if (Monocle.Browser.env.documentElementHasScrollbars) { + ce.ownerDocument.documentElement.style.overflow = 'hidden'; + } + + // Apply body style changes. + ce.style.cssText = rules; + + if (Monocle.Browser.env.scrollToApplyStyle) { + ce.scrollLeft = 0; + } + } + } + + + // Returns the element to which columns CSS should be applied. + // + function columnedElement() { + return p.page.m.activeFrame.contentDocument.body; + } + + + // Returns the width of the offsettable area of the columned element. By + // definition, the number of pages is always this divided by the + // width of a single page (eg, the client area of the columned element). + // + function columnedWidth() { + var bd = columnedElement(); + var de = p.page.m.activeFrame.contentDocument.documentElement; + + var w = Math.max(bd.scrollWidth, de.scrollWidth); + + // Add one because the final column doesn't have right gutter. + // w += k.GAP; + + if (!Monocle.Browser.env.widthsIgnoreTranslate && p.page.m.offset) { + w += p.page.m.offset; + } + return w; + } + + + function pageDimensions() { + var elem = p.page.m.sheafDiv; + var w = elem.clientWidth; + if (elem.getBoundingClientRect) { w = elem.getBoundingClientRect().width; } + if (Monocle.Browser.env.roundPageDimensions) { w = Math.round(w); } + return { col: w, width: w + k.GAP, height: elem.clientHeight } + } + + + function columnCount() { + return Math.ceil(columnedWidth() / pageDimensions().width) + } + + + function locusToOffset(locus) { + return pageDimensions().width * (locus.page - 1); + } + + + // Moves the columned element to the offset implied by the locus. + // + // The 'transition' argument is optional, allowing the translation to be + // animated. If not given, no change is made to the columned element's + // transition property. + // + function translateToLocus(locus, transition) { + var offset = locusToOffset(locus); + p.page.m.offset = offset; + translateToOffset(offset, transition); + return offset; + } + + + function translateToOffset(offset, transition) { + var ce = columnedElement(); + if (transition) { + Monocle.Styles.affix(ce, "transition", transition); + } + // NB: can't use setX as it causes a flicker on iOS. + Monocle.Styles.affix(ce, "transform", "translateX(-"+offset+"px)"); + } + + + function percentageThroughOfNode(target) { + if (!target) { return 0; } + var doc = p.page.m.activeFrame.contentDocument; + var offset = 0; + if (Monocle.Browser.env.findNodesByScrolling) { + // First, remove translation... + translateToOffset(0); + + // Store scroll offsets for all windows. + var win = s = p.page.m.activeFrame.contentWindow; + var scrollers = [ + [win, win.scrollX, win.scrollY], + [window, window.scrollX, window.scrollY] + ]; + //while (s != s.parent) { scrollers.push([s, s.scrollX]); s = s.parent; } + + if (Monocle.Browser.env.sheafIsScroller) { + var scroller = p.page.m.sheafDiv; + var x = scroller.scrollLeft; + target.scrollIntoView(); + offset = scroller.scrollLeft; + } else { + var scroller = win; + var x = scroller.scrollX; + target.scrollIntoView(); + offset = scroller.scrollX; + } + + // Restore scroll offsets for all windows. + while (s = scrollers.shift()) { + s[0].scrollTo(s[1], s[2]); + } + + // ... finally, replace translation. + translateToOffset(p.page.m.offset); + } else { + offset = target.getBoundingClientRect().left; + offset -= doc.body.getBoundingClientRect().left; + } + + // We know at least 1px will be visible, and offset should not be 0. + offset += 1; + + // Percent is the offset divided by the total width of the component. + var percent = offset / (p.length * p.width); + + // Page number would be offset divided by the width of a single page. + // var pageNum = Math.ceil(offset / pageDimensions().width); + + return percent; + } + + + API.update = update; + API.percentageThroughOfNode = percentageThroughOfNode; + + API.locusToOffset = locusToOffset; + API.translateToLocus = translateToLocus; + + return API; +} + + +Monocle.Dimensions.Columns.STYLE = { + // Most of these are already applied to body, but they're repeated here + // in case columnedElement() is ever anything other than body. + "columned": { + "margin": "0", + "padding": "0", + "height": "100%", + "width": "100%", + "position": "absolute" + }, + "column-force": { + "min-width": "200%", + "overflow": "hidden" + } +} +; +Monocle.Flippers.Slider = function (reader) { + + var API = { constructor: Monocle.Flippers.Slider } + var k = API.constants = API.constructor; + var p = API.properties = { + reader: reader, + pageCount: 2, + activeIndex: 1, + turnData: {}, + nextPageReady: true + } + + + function initialize() { + p.reader.listen("monocle:componentchanging", showWaitControl); + } + + + function addPage(pageDiv) { + pageDiv.m.dimensions = new Monocle.Dimensions.Columns(pageDiv); + + // BROWSERHACK: Firefox 4 is prone to beachballing on the first page turn + // unless a zeroed translateX has been applied to the page div. + Monocle.Styles.setX(pageDiv, 0); + } + + + function visiblePages() { + return [upperPage()]; + } + + + function listenForInteraction(panelClass) { + if (typeof panelClass != "function") { + panelClass = k.DEFAULT_PANELS_CLASS; + if (!panelClass) { + console.warn("Invalid panel class.") + } + } + p.panels = new panelClass( + API, + { + 'start': lift, + 'move': turning, + 'end': release, + 'cancel': release + } + ); + } + + + function getPlace(pageDiv) { + pageDiv = pageDiv || upperPage(); + return pageDiv.m ? pageDiv.m.place : null; + } + + + function moveTo(locus, callback) { + var cb = function () { + if (typeof callback == "function") { callback(); } + announceTurn(); + } + setPage(upperPage(), locus, function () { prepareNextPage(cb) }); + } + + + function setPage(pageDiv, locus, onLoad, onFail) { + p.reader.getBook().setOrLoadPageAt( + pageDiv, + locus, + function (locus) { + pageDiv.m.dimensions.translateToLocus(locus); + Monocle.defer(onLoad); + }, + onFail + ); + } + + + function upperPage() { + return p.reader.dom.find('page', p.activeIndex); + } + + + function lowerPage() { + return p.reader.dom.find('page', (p.activeIndex + 1) % 2); + } + + + function flipPages() { + upperPage().style.zIndex = 1; + lowerPage().style.zIndex = 2; + return p.activeIndex = (p.activeIndex + 1) % 2; + } + + + function lift(dir, boxPointX) { + if (p.turnData.lifting || p.turnData.releasing) { return; } + + p.reader.selection.deselect(); + + p.turnData.points = { + start: boxPointX, + min: boxPointX, + max: boxPointX + } + p.turnData.lifting = true; + + var place = getPlace(); + + if (dir == k.FORWARDS) { + if (place.onLastPageOfBook()) { + p.reader.dispatchEvent( + 'monocle:boundaryend', + { + locus: getPlace().getLocus({ direction : dir }), + page: upperPage() + } + ); + resetTurnData(); + return; + } + onGoingForward(boxPointX); + } else if (dir == k.BACKWARDS) { + if (place.onFirstPageOfBook()) { + p.reader.dispatchEvent( + 'monocle:boundarystart', + { + locus: getPlace().getLocus({ direction : dir }), + page: upperPage() + } + ); + resetTurnData(); + return; + } + onGoingBackward(boxPointX); + } else { + console.warn("Invalid direction: " + dir); + } + } + + + function turning(dir, boxPointX) { + if (!p.turnData.points) { return; } + if (p.turnData.lifting || p.turnData.releasing) { return; } + checkPoint(boxPointX); + slideToCursor(boxPointX, null, "0"); + } + + + function release(dir, boxPointX) { + if (!p.turnData.points) { + return; + } + if (p.turnData.lifting) { + p.turnData.releaseArgs = [dir, boxPointX]; + return; + } + if (p.turnData.releasing) { + return; + } + + checkPoint(boxPointX); + + p.turnData.releasing = true; + + if (dir == k.FORWARDS) { + if ( + p.turnData.points.tap || + p.turnData.points.start - boxPointX > 60 || + p.turnData.points.min >= boxPointX + ) { + // Completing forward turn + slideOut(afterGoingForward); + } else { + // Cancelling forward turn + slideIn(afterCancellingForward); + } + } else if (dir == k.BACKWARDS) { + if ( + p.turnData.points.tap || + boxPointX - p.turnData.points.start > 60 || + p.turnData.points.max <= boxPointX + ) { + // Completing backward turn + slideIn(afterGoingBackward); + } else { + // Cancelling backward turn + slideOut(afterCancellingBackward); + } + } else { + console.warn("Invalid direction: " + dir); + } + } + + + function checkPoint(boxPointX) { + p.turnData.points.min = Math.min(p.turnData.points.min, boxPointX); + p.turnData.points.max = Math.max(p.turnData.points.max, boxPointX); + p.turnData.points.tap = p.turnData.points.max - p.turnData.points.min < 10; + } + + + function onGoingForward(x) { + if (p.nextPageReady == false) { + prepareNextPage(function () { lifted(x); }, resetTurnData); + } else { + lifted(x); + } + } + + + function onGoingBackward(x) { + var lp = lowerPage(), up = upperPage(); + var onFail = function () { slideOut(afterCancellingBackward); } + + if (Monocle.Browser.env.offscreenRenderingClipped) { + // set lower to "the page before upper" + setPage( + lp, + getPlace(up).getLocus({ direction: k.BACKWARDS }), + function () { + // flip lower to upper, ready to slide in from left + flipPages(); + // move lower off the screen to the left + jumpOut(lp, function () { lifted(x); }); + }, + onFail + ); + } else { + jumpOut(lp, function () { + flipPages(); + setPage( + lp, + getPlace(up).getLocus({ direction: k.BACKWARDS }), + function () { lifted(x); }, + onFail + ); + }); + } + } + + + function afterGoingForward() { + var up = upperPage(), lp = lowerPage(); + flipPages(); + jumpIn(up, function () { prepareNextPage(announceTurn); }); + } + + + function afterGoingBackward() { + announceTurn(); + } + + + function afterCancellingForward() { + announceCancel(); + } + + + function afterCancellingBackward() { + flipPages(); // flip upper to lower + jumpIn(lowerPage(), function () { prepareNextPage(announceCancel); }); + } + + + // Prepares the lower page to show the next page after the current page, + // and calls onLoad when done. + // + // Note that if the next page is a new component, and it fails to load, + // onFail will be called. If onFail is not supplied, onLoad will be called. + // + function prepareNextPage(onLoad, onFail) { + setPage( + lowerPage(), + getPlace().getLocus({ direction: k.FORWARDS }), + onLoad, + function () { + onFail ? onFail() : onLoad(); + p.nextPageReady = false; + } + ); + } + + + function lifted(x) { + p.turnData.lifting = false; + p.reader.dispatchEvent('monocle:turning'); + var releaseArgs = p.turnData.releaseArgs; + if (releaseArgs) { + p.turnData.releaseArgs = null; + release(releaseArgs[0], releaseArgs[1]); + } else if (x) { + slideToCursor(x); + } + } + + + function announceTurn() { + p.nextPageReady = true; + p.reader.dispatchEvent('monocle:turn'); + resetTurnData(); + } + + + function announceCancel() { + p.reader.dispatchEvent('monocle:turn:cancel'); + resetTurnData(); + } + + + function resetTurnData() { + hideWaitControl(); + p.turnData = {}; + } + + + function setX(elem, x, options, callback) { + var duration, transition; + + if (!options.duration) { + duration = 0; + } else { + duration = parseInt(options.duration); + } + + if (Monocle.Browser.env.supportsTransition) { + Monocle.Styles.transitionFor( + elem, + 'transform', + duration, + options.timing, + options.delay + ); + + if (Monocle.Browser.env.supportsTransform3d) { + Monocle.Styles.affix(elem, 'transform', 'translate3d('+x+'px,0,0)'); + } else { + Monocle.Styles.affix(elem, 'transform', 'translateX('+x+'px)'); + } + + if (typeof callback == "function") { + if (duration && Monocle.Styles.getX(elem) != x) { + Monocle.Events.afterTransition(elem, callback); + } else { + Monocle.defer(callback); + } + } + } else { + // Old-school JS animation. + elem.currX = elem.currX || 0; + var completeTransition = function () { + elem.currX = x; + Monocle.Styles.setX(elem, x); + if (typeof callback == "function") { callback(); } + } + if (!duration) { + completeTransition(); + } else { + var stamp = (new Date()).getTime(); + var frameRate = 40; + var step = (x - elem.currX) * (frameRate / duration); + var stepFn = function () { + var destX = elem.currX + step; + var timeElapsed = ((new Date()).getTime() - stamp) >= duration; + var pastDest = (destX > x && elem.currX < x) || + (destX < x && elem.currX > x); + if (timeElapsed || pastDest) { + completeTransition(); + } else { + Monocle.Styles.setX(elem, destX); + elem.currX = destX; + setTimeout(stepFn, frameRate); + } + } + stepFn(); + } + } + } + + + function jumpIn(pageDiv, callback) { + opts = { duration: (Monocle.Browser.env.stickySlideOut ? 1 : 0) } + setX(pageDiv, 0, opts, callback); + } + + + function jumpOut(pageDiv, callback) { + setX(pageDiv, 0 - pageDiv.offsetWidth, { duration: 0 }, callback); + } + + + // NB: Slides are always done by the visible upper page. + + function slideIn(callback) { + setX(upperPage(), 0, slideOpts(), callback); + } + + + function slideOut(callback) { + setX(upperPage(), 0 - upperPage().offsetWidth, slideOpts(), callback); + } + + + function slideToCursor(cursorX, callback, duration) { + setX( + upperPage(), + Math.min(0, cursorX - upperPage().offsetWidth), + { duration: duration || k.FOLLOW_DURATION }, + callback + ); + } + + + function slideOpts() { + var opts = { timing: 'ease-in', duration: 320 } + var now = (new Date()).getTime(); + if (p.lastSlide && now - p.lastSlide < 1500) { opts.duration *= 0.5; } + p.lastSlide = now; + return opts; + } + + + function ensureWaitControl() { + if (p.waitControl) { return; } + p.waitControl = { + createControlElements: function (holder) { + return holder.dom.make('div', 'flippers_slider_wait'); + } + } + p.reader.addControl(p.waitControl, 'page'); + } + + + function showWaitControl() { + ensureWaitControl(); + p.reader.dom.find('flippers_slider_wait', 0).style.opacity = 1; + p.reader.dom.find('flippers_slider_wait', 1).style.opacity = 1; + } + + + function hideWaitControl() { + ensureWaitControl(); + p.reader.dom.find('flippers_slider_wait', 0).style.opacity = 0; + p.reader.dom.find('flippers_slider_wait', 1).style.opacity = 0; + } + + + // THIS IS THE CORE API THAT ALL FLIPPERS MUST PROVIDE. + API.pageCount = p.pageCount; + API.addPage = addPage; + API.getPlace = getPlace; + API.moveTo = moveTo; + API.listenForInteraction = listenForInteraction; + + // OPTIONAL API - WILL BE INVOKED (WHERE RELEVANT) IF PROVIDED. + API.visiblePages = visiblePages; + + initialize(); + + return API; +} + + +// Constants +Monocle.Flippers.Slider.DEFAULT_PANELS_CLASS = Monocle.Panels.TwoPane; +Monocle.Flippers.Slider.FORWARDS = 1; +Monocle.Flippers.Slider.BACKWARDS = -1; +Monocle.Flippers.Slider.FOLLOW_DURATION = 100; +Monocle.Flippers.Scroller = function (reader, setPageFn) { + + var API = { constructor: Monocle.Flippers.Scroller } + var k = API.constants = API.constructor; + var p = API.properties = { + pageCount: 1, + duration: 300 + } + + + function initialize() { + p.reader = reader; + p.setPageFn = setPageFn; + } + + + function addPage(pageDiv) { + pageDiv.m.dimensions = new Monocle.Dimensions.Columns(pageDiv); + } + + + function page() { + return p.reader.dom.find('page'); + } + + + function listenForInteraction(panelClass) { + if (typeof panelClass != "function") { + panelClass = k.DEFAULT_PANELS_CLASS; + } + p.panels = new panelClass( + API, + { + 'end': turn + } + ); + } + + + function turn(dir) { + if (p.turning) { return; } + p.reader.selection.deselect(); + moveTo({ page: getPlace().pageNumber() + dir}); + p.reader.dispatchEvent('monocle:turning'); + } + + + function getPlace() { + return page().m.place; + } + + + function moveTo(locus, callback) { + var fn = frameToLocus; + if (typeof callback == "function") { + fn = function (locus) { frameToLocus(locus); callback(locus); } + } + p.reader.getBook().setOrLoadPageAt(page(), locus, fn); + } + + + function frameToLocus(locus) { + if (locus.boundarystart || locus.boundaryend) { return; } + p.turning = true; + var dims = page().m.dimensions; + var fr = page().m.activeFrame; + var bdy = fr.contentDocument.body; + var anim = true; + if (p.activeComponent != fr.m.component) { + // No animation. + p.activeComponent = fr.m.component; + dims.translateToLocus(locus, "none"); + Monocle.defer(turned); + } else if (Monocle.Browser.env.supportsTransition) { + // Native animation. + dims.translateToLocus(locus, p.duration+"ms ease-in 0ms"); + Monocle.Events.afterTransition(bdy, turned); + } else { + // Old-school JS animation. + var x = dims.locusToOffset(locus); + var finalX = 0 - x; + var stamp = (new Date()).getTime(); + var frameRate = 40; + var currX = p.currX || 0; + var step = (finalX - currX) * (frameRate / p.duration); + var stepFn = function () { + var destX = currX + step; + if ( + (new Date()).getTime() - stamp > p.duration || + Math.abs(currX - finalX) <= Math.abs((currX + step) - finalX) + ) { + Monocle.Styles.setX(bdy, finalX); + turned(); + } else { + Monocle.Styles.setX(bdy, destX); + currX = destX; + setTimeout(stepFn, frameRate); + } + p.currX = destX; + } + stepFn(); + } + } + + + function turned() { + p.turning = false; + p.reader.dispatchEvent('monocle:turn'); + } + + + // THIS IS THE CORE API THAT ALL FLIPPERS MUST PROVIDE. + API.pageCount = p.pageCount; + API.addPage = addPage; + API.getPlace = getPlace; + API.moveTo = moveTo; + API.listenForInteraction = listenForInteraction; + + initialize(); + + return API; +} + +Monocle.Flippers.Scroller.speed = 200; // How long the animation takes +Monocle.Flippers.Scroller.rate = 20; // frame-rate of the animation +Monocle.Flippers.Scroller.FORWARDS = 1; +Monocle.Flippers.Scroller.BACKWARDS = -1; +Monocle.Flippers.Scroller.DEFAULT_PANELS_CLASS = Monocle.Panels.TwoPane; +Monocle.Flippers.Instant = function (reader) { + + var API = { constructor: Monocle.Flippers.Instant } + var k = API.constants = API.constructor; + var p = API.properties = { + pageCount: 1 + } + + + function initialize() { + p.reader = reader; + } + + + function addPage(pageDiv) { + pageDiv.m.dimensions = new Monocle.Dimensions.Columns(pageDiv); + } + + + function getPlace() { + return page().m.place; + } + + + function moveTo(locus, callback) { + var fn = frameToLocus; + if (typeof callback == "function") { + fn = function (locus) { frameToLocus(locus); callback(locus); } + } + p.reader.getBook().setOrLoadPageAt(page(), locus, fn); + } + + + function listenForInteraction(panelClass) { + if (typeof panelClass != "function") { + if (Monocle.Browser.on.Kindle3) { + panelClass = Monocle.Panels.eInk; + } + panelClass = panelClass || k.DEFAULT_PANELS_CLASS; + } + if (!panelClass) { throw("Panels not found."); } + p.panels = new panelClass(API, { 'end': turn }); + } + + + function page() { + return p.reader.dom.find('page'); + } + + + function turn(dir) { + p.reader.selection.deselect(); + moveTo({ page: getPlace().pageNumber() + dir}); + p.reader.dispatchEvent('monocle:turning'); + } + + + function frameToLocus(locus) { + page().m.dimensions.translateToLocus(locus); + Monocle.defer(function () { p.reader.dispatchEvent('monocle:turn'); }); + } + + + // THIS IS THE CORE API THAT ALL FLIPPERS MUST PROVIDE. + API.pageCount = p.pageCount; + API.addPage = addPage; + API.getPlace = getPlace; + API.moveTo = moveTo; + API.listenForInteraction = listenForInteraction; + + initialize(); + + return API; +} + +Monocle.Flippers.Instant.FORWARDS = 1; +Monocle.Flippers.Instant.BACKWARDS = -1; +Monocle.Flippers.Instant.DEFAULT_PANELS_CLASS = Monocle.Panels.TwoPane; + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/monocle/scripts/monoctrl.js b/resources/monocle/scripts/monoctrl.js new file mode 100644 index 0000000..5e71552 --- /dev/null +++ b/resources/monocle/scripts/monoctrl.js @@ -0,0 +1,985 @@ +Monocle.Controls.Contents = function (reader) { + + var API = { constructor: Monocle.Controls.Contents } + var k = API.constants = API.constructor; + var p = API.properties = { + reader: reader + } + + + function createControlElements() { + var div = reader.dom.make('div', 'controls_contents_container'); + contentsForBook(div, reader.getBook()); + return div; + } + + + function contentsForBook(div, book) { + while (div.hasChildNodes()) { + div.removeChild(div.firstChild); + } + var list = div.dom.append('ol', 'controls_contents_list'); + + var contents = book.properties.contents; + for (var i = 0; i < contents.length; ++i) { + chapterBuilder(list, contents[i], 0); + } + } + + + function chapterBuilder(list, chp, padLvl) { + var index = list.childNodes.length; + var li = list.dom.append('li', 'controls_contents_chapter', index); + var span = li.dom.append( + 'span', + 'controls_contents_chapterTitle', + index, + { html: chp.title } + ); + span.style.paddingLeft = padLvl + "em"; + + var invoked = function () { + p.reader.skipToChapter(chp.src); + p.reader.hideControl(API); + } + + Monocle.Events.listenForTap(li, invoked, 'controls_contents_chapter_active'); + + if (chp.children) { + for (var i = 0; i < chp.children.length; ++i) { + chapterBuilder(list, chp.children[i], padLvl + 1); + } + } + } + + + API.createControlElements = createControlElements; + + return API; +} +; +Monocle.Controls.Magnifier = function (reader) { + + var API = { constructor: Monocle.Controls.Magnifier } + var k = API.constants = API.constructor; + var p = API.properties = { + buttons: [], + magnified: false + } + + + function initialize() { + p.reader = reader; + } + + + function createControlElements(holder) { + var btn = holder.dom.make('div', 'controls_magnifier_button'); + btn.smallA = btn.dom.append('span', 'controls_magnifier_a', { text: 'A' }); + btn.largeA = btn.dom.append('span', 'controls_magnifier_A', { text: 'A' }); + p.buttons.push(btn); + Monocle.Events.listenForTap(btn, toggleMagnification); + return btn; + } + + + function toggleMagnification(evt) { + var opacities; + p.magnified = !p.magnified; + if (p.magnified) { + opacities = [0.3, 1]; + p.reader.formatting.setFontScale(k.MAGNIFICATION, true); + } else { + opacities = [1, 0.3]; + p.reader.formatting.setFontScale(null, true); + } + + for (var i = 0; i < p.buttons.length; i++) { + p.buttons[i].smallA.style.opacity = opacities[0]; + p.buttons[i].largeA.style.opacity = opacities[1]; + } + } + + API.createControlElements = createControlElements; + + initialize(); + + return API; +} + + +Monocle.Controls.Magnifier.MAGNIFICATION = 1.2; +// A panel is an invisible column of interactivity. When contact occurs +// (mousedown, touchstart), the panel expands to the full width of its +// container, to catch all interaction events and prevent them from hitting +// other things. +// +// Panels are used primarily to provide hit zones for page flipping +// interactions, but you can do whatever you like with them. +// +// After instantiating a panel and adding it to the reader as a control, +// you can call listenTo() with a hash of methods for any of 'start', 'move' +// 'end' and 'cancel'. +// +Monocle.Controls.Panel = function () { + + var API = { constructor: Monocle.Controls.Panel } + var k = API.constants = API.constructor; + var p = API.properties = { + evtCallbacks: {} + } + + function createControlElements(cntr) { + p.div = cntr.dom.make('div', k.CLS.panel); + p.div.dom.setStyles(k.DEFAULT_STYLES); + Monocle.Events.listenForContact( + p.div, + { + 'start': start, + 'move': move, + 'end': end, + 'cancel': cancel + }, + { useCapture: false } + ); + return p.div; + } + + + function setDirection(dir) { + p.direction = dir; + } + + + function listenTo(evtCallbacks) { + p.evtCallbacks = evtCallbacks; + } + + + function deafen() { + p.evtCallbacks = {} + } + + + function start(evt) { + p.contact = true; + evt.m.offsetX += p.div.offsetLeft; + evt.m.offsetY += p.div.offsetTop; + expand(); + invoke('start', evt); + } + + + function move(evt) { + if (!p.contact) { + return; + } + invoke('move', evt); + } + + + function end(evt) { + if (!p.contact) { + return; + } + Monocle.Events.deafenForContact(p.div, p.listeners); + contract(); + p.contact = false; + invoke('end', evt); + } + + + function cancel(evt) { + if (!p.contact) { + return; + } + Monocle.Events.deafenForContact(p.div, p.listeners); + contract(); + p.contact = false; + invoke('cancel', evt); + } + + + function invoke(evtType, evt) { + if (p.evtCallbacks[evtType]) { + p.evtCallbacks[evtType](p.direction, evt.m.offsetX, evt.m.offsetY, API); + } + evt.preventDefault(); + } + + + function expand() { + if (p.expanded) { + return; + } + p.div.dom.addClass(k.CLS.expanded); + p.expanded = true; + } + + + function contract(evt) { + if (!p.expanded) { + return; + } + p.div.dom.removeClass(k.CLS.expanded); + p.expanded = false; + } + + + API.createControlElements = createControlElements; + API.listenTo = listenTo; + API.deafen = deafen; + API.expand = expand; + API.contract = contract; + API.setDirection = setDirection; + + return API; +} + + +Monocle.Controls.Panel.CLS = { + panel: 'panel', + expanded: 'controls_panel_expanded' +} +Monocle.Controls.Panel.DEFAULT_STYLES = { + position: 'absolute', + height: '100%' +} +; +Monocle.Controls.PlaceSaver = function (bookId) { + + var API = { constructor: Monocle.Controls.PlaceSaver } + var k = API.constants = API.constructor; + var p = API.properties = {} + + + function initialize() { + applyToBook(bookId); + } + + + function assignToReader(reader) { + p.reader = reader; + p.reader.listen('monocle:turn', savePlaceToCookie); + } + + + function applyToBook(bookId) { + p.bkTitle = bookId.toLowerCase().replace(/[^a-z0-9]/g, ''); + p.prefix = k.COOKIE_NAMESPACE + p.bkTitle + "."; + } + + + function setCookie(key, value, days) { + var expires = ""; + if (days) { + var d = new Date(); + d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); + expires = "; expires="+d.toGMTString(); + } + var path = "; path=/"; + document.cookie = p.prefix + key + "=" + value + expires + path; + return value; + } + + + function getCookie(key) { + if (!document.cookie) { + return null; + } + var regex = new RegExp(p.prefix + key + "=(.+?)(;|$)"); + var matches = document.cookie.match(regex); + if (matches) { + return matches[1]; + } else { + return null; + } + } + + + function savePlaceToCookie() { + var place = p.reader.getPlace(); + setCookie( + "component", + encodeURIComponent(place.componentId()), + k.COOKIE_EXPIRES_IN_DAYS + ); + setCookie( + "percent", + place.percentageThrough(), + k.COOKIE_EXPIRES_IN_DAYS + ); + } + + + function savedPlace() { + var locus = { + componentId: getCookie('component'), + percent: getCookie('percent') + } + if (locus.componentId && locus.percent) { + locus.componentId = decodeURIComponent(locus.componentId); + locus.percent = parseFloat(locus.percent); + return locus; + } else { + return null; + } + } + + + function restorePlace() { + var locus = savedPlace(); + if (locus) { + p.reader.moveTo(locus); + } + } + + + API.assignToReader = assignToReader; + API.savedPlace = savedPlace; + API.restorePlace = restorePlace; + + initialize(); + + return API; +} + +Monocle.Controls.PlaceSaver.COOKIE_NAMESPACE = "monocle.controls.placesaver."; +Monocle.Controls.PlaceSaver.COOKIE_EXPIRES_IN_DAYS = 7; // Set to 0 for session-based expiry. +; +Monocle.Controls.Scrubber = function (reader) { + + var API = { constructor: Monocle.Controls.Scrubber } + var k = API.constants = API.constructor; + var p = API.properties = {} + + + function initialize() { + p.reader = reader; + p.reader.listen('monocle:turn', updateNeedles); + updateNeedles(); + } + + + function pixelToPlace(x, cntr) { + if (!p.componentIds) { + p.componentIds = p.reader.getBook().properties.componentIds; + p.componentWidth = 100 / p.componentIds.length; + } + var pc = (x / cntr.offsetWidth) * 100; + var cmpt = p.componentIds[Math.floor(pc / p.componentWidth)]; + var cmptPc = ((pc % p.componentWidth) / p.componentWidth); + return { componentId: cmpt, percentageThrough: cmptPc }; + } + + + function placeToPixel(place, cntr) { + if (!p.componentIds) { + p.componentIds = p.reader.getBook().properties.componentIds; + p.componentWidth = 100 / p.componentIds.length; + } + var componentIndex = p.componentIds.indexOf(place.componentId()); + var pc = p.componentWidth * componentIndex; + pc += place.percentageThrough() * p.componentWidth; + return Math.round((pc / 100) * cntr.offsetWidth); + } + + + function updateNeedles() { + if (p.hidden || !p.reader.dom.find(k.CLS.container)) { + return; + } + var place = p.reader.getPlace(); + var x = placeToPixel(place, p.reader.dom.find(k.CLS.container)); + var needle, i = 0; + for (var i = 0, needle; needle = p.reader.dom.find(k.CLS.needle, i); ++i) { + setX(needle, x - needle.offsetWidth / 2); + p.reader.dom.find(k.CLS.trail, i).style.width = x + "px"; + } + } + + + function setX(node, x) { + var cntr = p.reader.dom.find(k.CLS.container); + x = Math.min(cntr.offsetWidth - node.offsetWidth, x); + x = Math.max(x, 0); + Monocle.Styles.setX(node, x); + } + + + function createControlElements(holder) { + var cntr = holder.dom.make('div', k.CLS.container); + var track = cntr.dom.append('div', k.CLS.track); + var needleTrail = cntr.dom.append('div', k.CLS.trail); + var needle = cntr.dom.append('div', k.CLS.needle); + var bubble = cntr.dom.append('div', k.CLS.bubble); + + var cntrListeners, bodyListeners; + + var moveEvt = function (evt, x) { + evt.preventDefault(); + x = (typeof x == "number") ? x : evt.m.registrantX; + var place = pixelToPlace(x, cntr); + setX(needle, x - needle.offsetWidth / 2); + var book = p.reader.getBook(); + var chps = book.chaptersForComponent(place.componentId); + var cmptIndex = p.componentIds.indexOf(place.componentId); + var chp = chps[Math.floor(chps.length * place.percentageThrough)]; + if (cmptIndex > -1 && book.properties.components[cmptIndex]) { + var actualPlace = Monocle.Place.FromPercentageThrough( + book.properties.components[cmptIndex], + place.percentageThrough + ); + chp = actualPlace.chapterInfo() || chp; + } + + if (chp) { + bubble.innerHTML = chp.title; + } + setX(bubble, x - bubble.offsetWidth / 2); + + p.lastX = x; + return place; + } + + var endEvt = function (evt) { + var place = moveEvt(evt, p.lastX); + p.reader.moveTo({ + percent: place.percentageThrough, + componentId: place.componentId + }); + Monocle.Events.deafenForContact(cntr, cntrListeners); + Monocle.Events.deafenForContact(document.body, bodyListeners); + bubble.style.display = "none"; + } + + var startFn = function (evt) { + bubble.style.display = "block"; + moveEvt(evt); + cntrListeners = Monocle.Events.listenForContact( + cntr, + { move: moveEvt } + ); + bodyListeners = Monocle.Events.listenForContact( + document.body, + { end: endEvt } + ); + } + + Monocle.Events.listenForContact(cntr, { start: startFn }); + + return cntr; + } + + + API.createControlElements = createControlElements; + API.updateNeedles = updateNeedles; + + initialize(); + + return API; +} + +Monocle.Controls.Scrubber.CLS = { + container: 'controls_scrubber_container', + track: 'controls_scrubber_track', + needle: 'controls_scrubber_needle', + trail: 'controls_scrubber_trail', + bubble: 'controls_scrubber_bubble' +} +; +Monocle.Controls.Spinner = function (reader) { + + var API = { constructor: Monocle.Controls.Spinner } + var k = API.constants = API.constructor; + var p = API.properties = { + reader: reader, + divs: [], + repeaters: {}, + showForPages: [] + } + + + function createControlElements(cntr) { + var anim = cntr.dom.make('div', 'controls_spinner_anim'); + anim.dom.append('div', 'controls_spinner_inner'); + p.divs.push(anim); + return anim; + } + + + function registerSpinEvent(startEvtType, stopEvtType) { + var label = startEvtType; + p.reader.listen(startEvtType, function (evt) { spin(label, evt) }); + p.reader.listen(stopEvtType, function (evt) { spun(label, evt) }); + } + + + // Registers spin/spun event handlers for certain time-consuming events. + // + function listenForUsualDelays() { + registerSpinEvent('monocle:componentloading', 'monocle:componentloaded'); + registerSpinEvent('monocle:componentchanging', 'monocle:componentchange'); + registerSpinEvent('monocle:resizing', 'monocle:resize'); + registerSpinEvent('monocle:jumping', 'monocle:jump'); + registerSpinEvent('monocle:recalculating', 'monocle:recalculated'); + p.reader.listen('monocle:notfound', forceSpun); + p.reader.listen('monocle:componentfailed', forceSpun); + } + + + // Displays the spinner. Both arguments are optional. + // + function spin(label, evt) { + label = label || k.GENERIC_LABEL; + p.repeaters[label] = true; + p.reader.showControl(API); + + // If the delay is on a page other than the page we've been assigned to, + // don't show the animation. p.global ensures that if an event affects + // all pages, the animation is always shown, even if other events in this + // spin cycle are page-specific. + var page = (evt && evt.m && evt.m.page) ? evt.m.page : null; + if (page && p.divs.length > 1) { + p.showForPages[page.m.pageIndex] = true; + } else { + p.global = true; + p.reader.dispatchEvent('monocle:modal:on'); + } + for (var i = 0; i < p.divs.length; ++i) { + var show = (p.global || p.showForPages[i]) ? true : false; + p.divs[i].dom[show ? 'removeClass' : 'addClass']('dormant'); + } + } + + + // Stops displaying the spinner. Both arguments are optional. + // + function spun(label, evt) { + label = label || k.GENERIC_LABEL; + p.repeaters[label] = false; + for (var l in p.repeaters) { + if (p.repeaters[l]) { return; } + } + forceSpun(); + } + + + function forceSpun() { + if (p.global) { p.reader.dispatchEvent('monocle:modal:off'); } + p.global = false; + p.repeaters = {}; + p.showForPages = []; + for (var i = 0; i < p.divs.length; ++i) { + p.divs[i].dom.addClass('dormant'); + } + } + + + API.createControlElements = createControlElements; + API.registerSpinEvent = registerSpinEvent; + API.listenForUsualDelays = listenForUsualDelays; + API.spin = spin; + API.spun = spun; + API.forceSpun = forceSpun; + + return API; +} + +Monocle.Controls.Spinner.GENERIC_LABEL = "generic"; +Monocle.Controls.Stencil = function (reader, behaviorClasses) { + + var API = { constructor: Monocle.Controls.Stencil } + var k = API.constants = API.constructor; + var p = API.properties = { + reader: reader, + behaviors: [], + components: {}, + masks: [] + } + + + // Create the stencil container and listen for draw/update events. + // + function createControlElements(holder) { + behaviorClasses = behaviorClasses || k.DEFAULT_BEHAVIORS; + for (var i = 0, ii = behaviorClasses.length; i < ii; ++i) { + addBehavior(behaviorClasses[i]); + } + p.container = holder.dom.make('div', k.CLS.container); + p.reader.listen('monocle:turning', hide); + p.reader.listen('monocle:turn:cancel', show); + p.reader.listen('monocle:turn', update); + p.reader.listen('monocle:stylesheetchange', update); + p.reader.listen('monocle:resize', update); + update(); + return p.container; + } + + + // Pass this method an object that responds to 'findElements(doc)' with + // an array of DOM elements for that document, and to 'fitMask(elem, mask)'. + // + // After you have added all your behaviors this way, you would typically + // call update() to make them take effect immediately. + // + function addBehavior(bhvrClass) { + var bhvr = new bhvrClass(API); + if (typeof bhvr.findElements != 'function') { + console.warn('Missing "findElements" method for behavior: %o', bhvr); + } + if (typeof bhvr.fitMask != 'function') { + console.warn('Missing "fitMask" method for behavior: %o', bhvr); + } + p.behaviors.push(bhvr); + } + + + // Resets any pre-calculated rectangles for the active component, + // recalculates them, and forces masks to be "drawn" (moved into the new + // rectangular locations). + // + function update() { + var visPages = p.reader.visiblePages(); + if (!visPages || !visPages.length) { return; } + var pageDiv = visPages[0]; + var cmptId = pageComponentId(pageDiv); + if (!cmptId) { return; } + p.components[cmptId] = null; + calculateRectangles(pageDiv); + draw(); + } + + + function hide() { + p.container.style.display = 'none'; + } + + + function show() { + p.container.style.display = 'block'; + } + + + // Removes any existing masks. + function clear() { + while (p.container.childNodes.length) { + p.container.removeChild(p.container.lastChild); + } + } + + + // Aligns the stencil container to the shape of the page, then moves the + // masks to sit above any currently visible rectangles. + // + function draw() { + var pageDiv = p.reader.visiblePages()[0]; + var cmptId = pageComponentId(pageDiv); + if (!p.components[cmptId]) { + return; + } + + // Position the container. + alignToComponent(pageDiv); + + // Clear old masks. + clear(); + + // Layout the masks. + if (!p.disabled) { + show(); + var rects = p.components[cmptId]; + if (rects && rects.length) { + layoutRectangles(pageDiv, rects); + } + } + } + + + // Iterate over all the elements in the active component, and + // create an array of rectangular points corresponding to their positions. + // + function calculateRectangles(pageDiv) { + var cmptId = pageComponentId(pageDiv); + if (!p.components[cmptId]) { + p.components[cmptId] = []; + } else { + return; + } + + var doc = pageDiv.m.activeFrame.contentDocument; + var offset = getOffset(pageDiv); + + for (var b = 0, bb = p.behaviors.length; b < bb; ++b) { + var bhvr = p.behaviors[b]; + var elems = bhvr.findElements(doc); + for (var i = 0; i < elems.length; ++i) { + var elem = elems[i]; + if (elem.getClientRects) { + var r = elem.getClientRects(); + for (var j = 0; j < r.length; j++) { + p.components[cmptId].push({ + element: elem, + behavior: bhvr, + left: Math.ceil(r[j].left + offset.l), + top: Math.ceil(r[j].top), + width: Math.floor(r[j].width), + height: Math.floor(r[j].height) + }); + } + } + } + } + + return p.components[cmptId]; + } + + + // Update location of visible rectangles - creating as required. + // + function layoutRectangles(pageDiv, rects) { + var offset = getOffset(pageDiv); + var visRects = []; + for (var i = 0; i < rects.length; ++i) { + if (rectVisible(rects[i], offset.l, offset.l + offset.w)) { + visRects.push(rects[i]); + } + } + + for (i = 0; i < visRects.length; ++i) { + var r = visRects[i]; + var cr = { + left: r.left - offset.l, + top: r.top, + width: r.width, + height: r.height + }; + var mask = createMask(r.element, r.behavior); + mask.dom.setStyles({ + display: 'block', + left: cr.left+"px", + top: cr.top+"px", + width: cr.width+"px", + height: cr.height+"px", + position: 'absolute' + }); + mask.stencilRect = cr; + } + } + + + // Find the offset position in pixels from the left of the current page. + // + function getOffset(pageDiv) { + return { + l: pageDiv.m.offset || 0, + w: pageDiv.m.dimensions.properties.width + }; + } + + + // Is this area presently on the screen? + // + function rectVisible(rect, l, r) { + return rect.left >= l && rect.left < r; + } + + + // Returns the active component id for the given page, or the current + // page if no argument passed in. + // + function pageComponentId(pageDiv) { + pageDiv = pageDiv || p.reader.visiblePages()[0]; + if (!pageDiv.m.activeFrame.m.component) { return; } + return pageDiv.m.activeFrame.m.component.properties.id; + } + + + // Positions the stencil container over the active frame. + // + function alignToComponent(pageDiv) { + cmpt = pageDiv.m.activeFrame.parentNode; + p.container.dom.setStyles({ + left: cmpt.offsetLeft+"px", + top: cmpt.offsetTop+"px" + }); + } + + + function createMask(element, bhvr) { + var mask = p.container.dom.append(bhvr.maskTagName || 'div', k.CLS.mask); + Monocle.Events.listenForContact(mask, { + start: function () { p.reader.dispatchEvent('monocle:magic:halt'); }, + move: function (evt) { evt.preventDefault(); }, + end: function () { p.reader.dispatchEvent('monocle:magic:init'); } + }); + bhvr.fitMask(element, mask); + return mask; + } + + + // Make the active masks visible (by giving them a class -- override style + // in monoctrl.css). + // + function toggleHighlights() { + var cls = k.CLS.highlights; + if (p.container.dom.hasClass(cls)) { + p.container.dom.removeClass(cls); + } else { + p.container.dom.addClass(cls); + } + } + + + function disable() { + p.disabled = true; + draw(); + } + + + function enable() { + p.disabled = false; + draw(); + } + + + function filterElement(elem, behavior) { + if (typeof behavior.filterElement == 'function') { + return behavior.filterElement(elem); + } + return elem; + } + + + function maskAssigned(elem, mask, behavior) { + if (typeof behavior.maskAssigned == 'function') { + return behavior.maskAssigned(elem, mask); + } + return false; + } + + + API.createControlElements = createControlElements; + API.addBehavior = addBehavior; + API.draw = draw; + API.update = update; + API.toggleHighlights = toggleHighlights; + + return API; +} + + +Monocle.Controls.Stencil.CLS = { + container: 'controls_stencil_container', + mask: 'controls_stencil_mask', + highlights: 'controls_stencil_highlighted' +} + + + +Monocle.Controls.Stencil.Links = function (stencil) { + var API = { constructor: Monocle.Controls.Stencil.Links } + + // Optionally specify the HTML tagname of the mask. + API.maskTagName = 'a'; + + // Returns an array of all the elements in the given doc that should + // be covered with a stencil mask for interactivity. + // + // (Hint: doc.querySelectorAll() is your friend.) + // + API.findElements = function (doc) { + return doc.querySelectorAll('a[href]'); + } + + + // Return an element. It should usually be a child of the container element, + // with a className of the given maskClass. You set up the interactivity of + // the mask element here. + // + API.fitMask = function (link, mask) { + var hrefObject = deconstructHref(link); + + if (hrefObject.internal) { + mask.setAttribute('href', 'javascript:"Skip to chapter"'); + mask.onclick = function (evt) { + stencil.properties.reader.skipToChapter(hrefObject.internal); + evt.preventDefault(); + return false; + } + } else { + mask.setAttribute('href', hrefObject.external); + mask.setAttribute('target', '_blank'); + mask.onclick = function (evt) { return true; } + } + + link.onclick = function (evt) { + evt.preventDefault(); + return false; + } + } + + + // Returns an object with either: + // + // - an 'external' property -- an absolute URL with a protocol, + // host & etc, which should be treated as an external resource (eg, + // open in new window) + // + // OR + // + // - an 'internal' property -- a relative URL (with optional hash anchor), + // that is treated as a link to component in the book + // + // A weird but useful property of tags is that while + // link.getAttribute('href') will return the actual string value of the + // attribute (eg, 'foo.html'), link.href will return the absolute URL (eg, + // 'http://example.com/monocles/foo.html'). + // + function deconstructHref(elem) { + var loc = document.location; + var origin = loc.protocol+'//'+loc.host; + var href = elem.href; + var path = href.substring(origin.length); + var ext = { external: href }; + + // Anchor tags with 'target' attributes are always external URLs. + if (elem.getAttribute('target')) { + return ext; + } + // URLs with a different protocol or domain are always external. + //console.log("Domain test: %s <=> %s", origin, href); + if (href.indexOf(origin) != 0) { + return ext; + } + + // If it is in a sub-path of the current path, it's internal. + var topPath = loc.pathname.replace(/[^\/]*\.[^\/]+$/,''); + if (topPath[topPath.length - 1] != '/') { + topPath += '/'; + } + //console.log("Sub-path test: %s <=> %s", topPath, path); + if (path.indexOf(topPath) == 0) { + return { internal: path.substring(topPath.length) } + } + + // If it's a root-relative URL and it's in our list of component ids, + // it's internal. + var cmptIds = stencil.properties.reader.getBook().properties.componentIds; + for (var i = 0, ii = cmptIds.length; i < ii; ++i) { + //console.log("Component test: %s <=> %s", cmptIds[i], path); + if (path.indexOf(cmptIds[i]) == 0) { + return { internal: path } + } + } + + // Otherwise it's external. + return ext; + } + + + return API; +} + + +Monocle.Controls.Stencil.DEFAULT_BEHAVIORS = [Monocle.Controls.Stencil.Links]; diff --git a/resources/monocle/styles/monocore.css b/resources/monocle/styles/monocore.css new file mode 100644 index 0000000..02b4951 --- /dev/null +++ b/resources/monocle/styles/monocore.css @@ -0,0 +1,195 @@ +/*=========================================================================== + +This is a base-level Monocle stylesheet. It assumes no class-prefix has been +given to the Reader during initialisation - if one has, you can copy and +modify this stylesheet accordingly. + +---------------------------------------------------------------------------*/ + +/* The reader object that holds pretty much everything. + * (A direct child of the element passed to reader initialisation). */ + +div.monelem_container { + background-color: black; +} + + +/* The div that mimics a leaf of paper in a book. */ +div.monelem_page { + background: white; + top: 0; + left: 0; + bottom: 3px; + right: 5px; + border-right: 1px solid #999; +} + + +/* The div within the page that determines page margins. */ +div.monelem_sheaf { + top: 1em; + left: 1em; + bottom: 1em; + right: 1em; +} + + +/* The iframe within the page that loads the content of the book. */ +div.monelem_component { +} + + +/* A panel that sits above the entire reader object, holding controls. */ +div.monelem_overlay { +} + + +/* A full-size panel to display an announcement (iframe or div) */ +div.monelem_billboard_container { + background: #FFF; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + z-index: 2000; + -webkit-transform: scale(0); + -moz-transform: scale(0); + transform: scale(0); + -webkit-transform-origin: -0 -0; + -moz-transform-origin: -0 -0; + transform-origin: -0 -0; +} + +.monelem_billboard_inner { + height: 100%; + width: 100%; + border: none; + overflow: auto; + /*-webkit-overflow-scrolling: touch;*/ /* This is sexy, but crashy. */ +} + +div.monelem_billboard_inner { + min-width: 100%; + min-height: 100%; + text-align: center; + vertical-align: middle; + display: -webkit-box; + -webkit-box-pack: center; + -webkit-box-align: center; +} + + +div.monelem_billboard_close { + position: absolute; + top: 0; + right: 0; + width: 50px; + height: 30px; + color: white; + background: #C00; + cursor: pointer; + border-bottom-left-radius: 4px; + text-shadow: 1px 1px 1px #900; + font: 9pt Helvetica Neue, Helvetica, sans-serif; +} + +div.monelem_billboard_close:after { + display: block; + content: 'Close'; + width: 100%; + line-height: 30px; + text-align: center; +} + +div.monelem_book_fatality { + font-family: Helvetica Neue, Helvetica, sans-serif; + margin: 0 auto; + max-width: 75%; +} + +div.monelem_book_fatality p { + line-height: 1.4; +} + + +/*=========================================================================== + PANELS +---------------------------------------------------------------------------*/ + + +.monelem_panels_imode_panel { + background: rgba(255,255,255,0.7); + opacity: 0; +} + +.monelem_panels_imode_backwardsPanel { + -webkit-box-shadow: 1px 1px 3px #777; + -moz-box-shadow: 1px 1px 3px #777; + box-shadow: 1px 1px 3px #777; +} + +.monelem_panels_imode_forwardsPanel { + -webkit-box-shadow: -1px 1px 3px #777; + -moz-box-shadow: -1px 1px 3px #777; + box-shadow: -1px 1px 3px #777; +} + +.monelem_panels_imode_centralPanel { +} + +.monelem_panels_imode_toggleIcon { + position: absolute; + right: 0; + bottom: 0; + width: 50px; + height: 50px; + background-repeat: no-repeat; + background-position: center center; +} + +/* If you modify this you could significantly change the way panels work. */ +div.monelem_controls_panel_expanded { + left: 0 !important; + width: 100% !important; + z-index: 1001 !important; +} + +/*=========================================================================== + Flippers +---------------------------------------------------------------------------*/ + +div.monelem_flippers_slider_wait { + position: absolute; + right: 0px; + top: 0px; + width: 92px; + height: 112px; + background-repeat: no-repeat; + -webkit-background-size: 100%; + -moz-background-size: 100%; + background-size: 100%; +} + +@media screen and (max-width: 640px) { + div.monelem_flippers_slider_wait { + width: 61px; + height: 75px; + } +} + + +/*=========================================================================== + DATA URIs + + These are data-uri packed images, inlined for loading speed and simplicity. + Placed at the end of this file because they're visually noisy... +---------------------------------------------------------------------------*/ + +div.monelem_panels_imode_toggleIcon { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB8AAAAaCAYAAABPY4eKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1%2B%2FAAAABV0RVh0Q3JlYXRpb24gVGltZQAzMC82LzEwBMfmVwAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNXG14zYAAANYSURBVEiJtdZbiNVVFMfxj8cx85JkIGlqSESgOGA9WIQgGmTRUyRaYFJDnUWYGV2eyiCpkIbEKJI1UqYvUkmFDxFBgpghonajSDCM7hcxLSnt4ulh%2F2c4HufMTOH8Xs75%2F%2Ffa67v3%2Bu%2B91hphGJWZNUzCXJyKiHd6xxqNhhGDTB6NOViAyzARY3EaP%2BNL7MCBiPi9Ze4leBlTsR9jcCnuiYgDbeGZeV4F7EINe7EP3%2BJ49W4GrsZ8NPAGXouIk5k5F93YFhHPVT5H4kbcjaX1ev3kWfDMPB9P4ko8ERE7BopONWcOVmMc1uBRrG8Oc5Ptq1hdr9cPdrQMTMUWfBQRCweD9ioiPsQtmbkeu7G8P3ClsZSI98EzcxqeUsLXM1RwZs7ErRiJKXgQN2Tmzoj4qsV2Hn7BYcq369UaHIqI5yPizyGCx2MPfsRVOBoR6%2FA%2BNmXmqCbbm%2FAiMiJO9cEzcwEuwLODwMZk5oXVLYA6PouIF%2FC6cvBgI37D0mreStyJroh4r9df785XYGtEHG8Hfnjb1w08Xu2qq3regtOZuaka2whV5NZieWY%2BhkV4ICJ2N%2FusZeYMJQm8NdCuuxdPH4HENGzsXjx9REQcqRxvR2dEfNBrHxF7lHywGPXW7085cEvwZkScHAheaRz%2BwngcqyAnlEPan%2Fbh5oj4rr%2FBDlyOXUMA%2Fx%2F9oFytM5SZs3t6epbWlOtxeJjg%2BzEmMye3vF%2BCYx2YhdFnTTs3OoQT2JqZ3TiC2zETyzrwrnIwhkMTqwVsxW24GLsiYmWj0dCBo2gNy7nSRfgpIjZjM6WU1ut1lHt%2BGLOHCd6J79sN1pSkMSUzJwwD%2FBoD5I9aRHyiFIVFQ3D2j1KR%2Fh7MMDPnY1JE7GwLr3434N5BnI3GFRiFzuai0Ub34aWBDGr0pcKPM%2FPpqovpT11KoVinNAvXt1lkLTNXKFesXU1HUz3HI0plWqW0QGcoIjYoERpMy7AS17b2da06o43KzLF4RanRzwwx3%2FfOHYW7lL5ubUR83p9do9Ho%2B99fDzcZDynfdxPejog%2FBoCOxHW4AxOwKiK%2BaGc%2FILzJ6ULcXznciwM4qFSzCUob3Km0UCeU3W5v5%2B8%2FwZsWMQvzlN1Nq8C%2F4ht8qkRm72B%2B%2BoP%2FC0sEOftJmUbfAAAAAElFTkSuQmCC); +} + +div.monelem_flippers_slider_wait { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFwAAABwCAMAAACkX%2BcnAAAB0VBMVEUAAACDg4OEhISFhYWGhoaHh4eIiIiJiIiJiYmKioqLi4uMjIyNjY2Ojo6Pj4%2BQkJCRkZGSkpKTk5OUlJSVlZWWlpaXl5eYmJiZmZmampqbm5ucnJycnJ2dnZ2dnZ6dnZ%2Benp6enp%2Bfn5%2Bfn6CgoKCgoKGhoKChoaGioqKjo6OkpKSlpaWmpaWmpqaoqKiqqqqrq6usrKytra2urq6wsLCxsbGzs7O0tLS0tLW1tbW1tba1tbe2tri4uLi4uLm4uLq6ury7u7y8vLy8vL28vL%2B9vb2%2Bvr6%2Bvr%2B%2Fv7%2B%2Fv8HAwMDAwMLAwMPBwcPCwsPExMTExMXFxcXGxsbHx8fIyMjJycrOztDOztHPz9DPz9HR0dTS0tTT09TT09XU1NbU1NfV1dfW1tjW1tnX19fX19rY2Nra2tva2tzd3eDe3t7f39%2Fh4eHi4uLl5enn5%2Bnp6ezp6e3q6u3q6u7r6%2B7r6%2B%2Fs7O%2Fs7PDt7fDt7fHu7vHu7vLv7%2B%2Fv7%2FLv7%2FPw8PDw8PPw8PTx8fTx8fXy8vXy8vbz8%2Fbz8%2Ff09Pf09Pj19fj19fn29vn39%2Fn39%2Fr4%2BPr4%2BPv5%2Bfv6%2Bvv6%2Bvz7%2B%2Fz7%2B%2F38%2FP39%2Ff39%2Ff7%2B%2Fv7%2B%2Fv%2F%2F%2F%2F%2BHSJEZAAAAAXRSTlMAQObYZgAAA5dJREFUaN61lk1uE0EQhd%2BrsQlREAgkFkQKLJByteQU3IIdd2OBYIFASFmAFLurWPT0uOfXme6aWUXy6PNL9XPXR3z6DSI93wQ0GkHjzweapM%2B%2Btn8SMAERPzKQQKN7IDRhD2APgkbumucvXp24T3s%2BH47H7%2F9U1AxmpvaDzV5IUMBfD0CbQXYPly93K%2BEiwneqphpMVc3e7p492zciQhGKNN2bX%2F42shJOEQFIQgAKgfgdpvFz7d58%2FPO4Fn5PiggBAUkAYhoUMJipwU5vhsfjWjhESMTsBChQVVMDYICadfjD4VAAFyGYZVcN7Vzar4iP6frkd5RuLjG7WlCFwdSy4ICtPlBAKJLNhYBq6HKf8IHrx4J7IQX5maqFLHeC3yrWwyEiFACSzlTVVFNuzQZTAG%2BrLoQwVT1kubvGF4wlVj2vi2isuvWrbiXJIUISYKwL5qpuWgbvXQHxSCeqbiXwvOrpClC1QdXViuAQUnpXgE1U%2FSb%2BUwVVF7JfdTWN2G4uFyiaeZz6oOpB1drzTF0sSw6ySdc5Y%2FZe1SPeCpPfS6p6yq4arK16V5eyAwWEp6oTEKpqewXEygBW9iMabzsAZjqoOkuTL227tjJvSg8UaG%2FGhW33obSK8d4dVj1eAV3VrXQsuBtXvd12XdWteCxg2nbobbuU2xQsHst42zHe6lllypOnbcdUeZ62HUzNoOXJz4vdpZXDz4rde5TDz4rdsQ6%2BLHZNxVjOip3VJD8ndjVtOSt2rEp%2BRuxCHXxZ7G6tCr4sdhUX1xPETmvhC2KndWNZFjtUjmVR7KRyLItiF2qTL4ndtdXCF8Tuqhq%2BIHaonfmi2Ek1fEHsQjV8YdtVt2VR7DzgM2J36QCfFbsbB%2Fi82MEBPit2HvBZsfMYy6zYuSSfq7oLfE7sLpzgk2J37QKfETt1gc%2BJnQ98Rux84NNiJ07wSbELTvBpsXOCT4rdRz%2F4WOzMCz4pdl7wKbGDG3xC7NzGMiV2jvCx2PnNfELsbvzgY7FrHOFjsXOEj7YdHeFjsfOF96sePOFjsXOED8XutSt8sO2uXOFDsfOFD6ruCx9U3Rc%2BEDt3eC52zvC%2B2DnD%2B2LnDe9V3RveEzt3eC527vBc7NzhudhtAe%2BuAH94VnV%2FeCZ2G8BzscMmUxdgi5lnYrcF%2FCR2wCZHSvftP9x2m8DTttsEnsRuK7hs8%2FPPxG4beCt2G8HbbbcNPG67reAUEfwHRePBMkvuZ4wAAAAASUVORK5CYII%3D); +} diff --git a/resources/monocle/styles/monoctrl.css b/resources/monocle/styles/monoctrl.css new file mode 100644 index 0000000..89e8ab6 --- /dev/null +++ b/resources/monocle/styles/monoctrl.css @@ -0,0 +1,169 @@ +/*=========================================================================== + CONTROLS + + The standard Monocle stylesheet for the optional Monocle controls. See + comments for monocore.css, which apply here too. +---------------------------------------------------------------------------*/ + +/* Contents */ + +div.monelem_controls_contents_container { + position: absolute; + width: 75%; + height: 75%; + left: 12.5%; + top: 12.5%; + background: #EEE; + border: 2px solid #F7F7F7; + border-radius: 9px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + -moz-border-radius: 9px; + -webkit-border-radius: 9px; + box-shadow: 1px 2px 6px rgba(0,0,0,0.5); + -moz-box-shadow: 1px 2px 6px rgba(0,0,0,0.5); + -webkit-box-shadow: 1px 2px 6px rgba(0,0,0,0.5); +} + +ol.monelem_controls_contents_list { + margin: 6px; + padding: 0; +} + +li.monelem_controls_contents_chapter { + list-style: none; + line-height: 220%; + padding-left: 1em; + padding-right: 2em; + border-bottom: 2px groove #FEFEFE; + cursor: pointer; +} + +li.monelem_controls_contents_chapter_active { + background: #999; + color: white; +} + +/* Magnifier */ + +.monelem_controls_magnifier_button { + cursor: pointer; + color: #555; + position: absolute; + top: 2px; + right: 10px; + padding: 0 2px; +} + +.monelem_controls_magnifier_a { + font-size: 11px; +} + +.monelem_controls_magnifier_A { + font-size: 18px; + opacity: 0.3; +} + + +/* Spinner */ + +.monelem_controls_spinner_anim { + position: absolute; + width: 100%; + height: 100%; + background-color: white; + background-repeat: no-repeat; + background-position: center center; +} +.monelem_controls_spinner_anim.monelem_dormant { + width: 0; + height: 0; +} + + +/* Scrubber */ + +div.monelem_controls_scrubber_container { + position: absolute; + left: 1em; + right: 1em; + bottom: 4px; + height: 30px; + background: rgba(255,255,255,0.8); +} + +div.monelem_controls_scrubber_track { + margin-top: 10px; + height: 5px; + border: 1px solid #999; + cursor: pointer; +} + +div.monelem_controls_scrubber_needle { + position: absolute; + width: 14px; + height: 14px; + top: 5px; + background: #CCC; + border: 1px solid #999; + border-radius: 8px; + -moz-border-radius: 8px; + -webkit-border-radius: 8px; +} + +div.monelem_controls_scrubber_trail { + position: absolute; + background: #DDD; + top: 11px; + left: 1px; + height: 5px; +} + +div.monelem_controls_scrubber_bubble { + display: none; + position: absolute; + padding: 1em; + min-width: 20%; + max-width: 30%; + bottom: 2.5em; + background: rgba(0, 0, 0, 0.9); + color: #CCC; + font: bold 12px Lucida Grande, Tahoma, Helvetica, Arial, sans-serif; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + border-radius: 10px; + -moz-border-radius: 10px; + -webkit-border-radius: 10px; +} + + +/* Stencil */ +div.monelem_controls_stencil_container { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; +} + +.monelem_controls_stencil_mask { + display: block; + position: absolute; +} + +div.monelem_controls_stencil_highlighted .monelem_controls_stencil_mask { + background: rgba(0,0,255,0.15); +} + + +/*=========================================================================== + DATA URIs + + These are data-uri packed images, inlined for loading speed and simplicity. + Placed at the end of this file because they're visually noisy... +---------------------------------------------------------------------------*/ + +div.monelem_controls_spinner_anim { + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAA0CAMAAAANBM47AAAAA3NCSVQICAjb4U/gAAAACXBIWXMAAAsSAAALEgHS3X78AAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M1cbXjNgAAABV0RVh0Q3JlYXRpb24gVGltZQAxNy81LzEwnOhoKAAAAE5QTFRFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxKKmWQAAABp0Uk5TAAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBl0wLilAAAC8klEQVQYGZXBB2LjOAADQFCimtVFEoD//9HLbrJxipzoZoBToYptUwV8V/Xrsc8RP6i7aduPXHI69mWIAR9UY6Is5rnCuTBsWXeLkijbTFOLf7okW6R8zxEnwphskfIrifJdW4u/RtlpbGLsdjoHfDNkSZTSNg192w3jchSJEtcawCRzDvgjLPINX1SbSSvNXcC7eNuLXpQuTFbp8CZkH/isyS68H0PAF+0iUzxoNni33HPAR51UxDHgRLObslLEw3TPFT7oKPqIeOImURs+WJ0CHlqKXgLOxL4NgyRqxbuqeMNDXURPOBNWSokquRRP+GeVOzwcLlpwJmx3WVJuY2ZRi1ezfOBhdNGGU52ZhrloBzqSucKLerdLxLtIKlc4Nd9LA6wuNTC5aAbQZzs3eFhE9Tg3mw2wqkQgHCZrTJK3iIcoasMTvXX0E30EAK2k+Wbrho8mky2eCLslSz3+2ERKucVHIZsbnqp2WWXEX60ossMnrakeP+jGocabg9SGzyaXHHDRpOIO/zRjDWCTNlzVsLjFm4bODapE33BZoke8mVy8oqXY4rLNXvFmEnXDKJYaly3SjlchkSOwiCngstFMeDXLE4CVygGX3e6FawUgzFIKANbiHHDZ7U4qL7c5SWzxYqFywGXjvVD3F3Zu8ccs5gqXzeYx7CTTWOOvnmTEZZu0ItSxrvAmZrrHZYme8dkhLbiqLkUDPlvMA1cNIiM+613Y4KJNSviiprTgmrrQM75arVzhkllUxFetqBlXVEXa8d0hMeKCxVSH73rRG37XidpxZlXRiN9UhYUtztRFVI+fhUPFE851KlSHn4TNxTueGU2yx3PVbipVeGpxIaeAJ2IynRv8YHEp3iNOjRRdGvxotGjONb7pD7M4RfyiK6ZclhYf1bdDprRW+FW9SZSUlqGtq1BVTTftRaKce1zS7bIpWyW/oK0i38tU4apupWyRsijKVhoj/o+6W45cJEoqaR+bgP8txH5a1nUZ2gq/+Q/51T5MhuG3fQAAAABJRU5ErkJggg==); +}