diff --git a/.Rbuildignore b/.Rbuildignore index 63fcfbd2d2..40641b002b 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -12,7 +12,7 @@ ^\.travis\.yml$ ^staticdocs$ ^tools$ -^srcjs$ +^srcts$ ^CONTRIBUTING.md$ ^cran-comments.md$ ^.*\.o$ diff --git a/.github/workflows/rituals.yaml b/.github/workflows/rituals.yaml index 462ef4eb2e..69642109ac 100644 --- a/.github/workflows/rituals.yaml +++ b/.github/workflows/rituals.yaml @@ -20,7 +20,7 @@ jobs: fail-fast: false matrix: config: - - { os: ubuntu-16.04, r: '4.0', rspm: "https://packagemanager.rstudio.com/all/__linux__/xenial/latest"} + - { os: ubuntu-16.04, r: '4.0', node: "14.x", rspm: "https://packagemanager.rstudio.com/all/__linux__/xenial/latest"} env: R_REMOTES_NO_ERRORS_FROM_WARNINGS: true @@ -110,7 +110,7 @@ jobs: - uses: actions/setup-node@v1 with: - node-version: '12.x' + node-version: ${{ matrix.config.node }} # https://github.com/actions/cache/blame/ccf96194800dbb7b7094edcd5a7cf3ec3c270f10/examples.md#L185-L200 - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -120,18 +120,18 @@ jobs: id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ matrix.config.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + key: ${{ matrix.config.os }}-${{ matrix.config.node }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | - ${{ matrix.config.os }}-yarn- + ${{ matrix.config.os }}-${{ matrix.config.node }}-yarn- - name: Build JS run: | - cd tools - yarn install --frozen-lockfile - yarn build - git add ../inst - git commit -m 'yarn build (GitHub Actions)' || echo "No yarn changes to commit" + cd srcts + tree src + yarn install --immutable && yarn build + git add ./src && git commit -m 'yarn lint (GitHub Actions)' || echo "No yarn lint changes to commit" + git add ../inst && git commit -m 'yarn build (GitHub Actions)' || echo "No yarn build changes to commit" - - name: Check node build + - name: Check JS build is latest run: | ./tools/checkJSCurrent.sh @@ -145,3 +145,9 @@ jobs: if: github.event_name == 'push' run: | git push https://${{github.actor}}:${{secrets.GITHUB_TOKEN}}@github.com/${{github.repository}}.git HEAD:${{ github.ref }} || echo "No changes to push" + + # Execute after pushing, as no updated files will be produced + - name: Test TypeScript code + run: | + cd srcts + yarn test diff --git a/NEWS.md b/NEWS.md index bc11ead220..daefba644f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,6 +9,8 @@ shiny 1.6.0.9000 ### Minor new features and improvements +* Shiny's core JavaScript code was converted to TypeScript. For the latest developement information, please see the [README.md in `./srcts`](https://github.com/rstudio/shiny/tree/master/srcts). (#3296) + * Switched from `digest::digest()` to `rlang::hash()` for hashing. (#3264) * Switched from internal `Stack` class to `fastmap::faststack()`, and used `fastmap::fastqueue()`. (#3176) diff --git a/inst/www/shared/shiny-es5.js b/inst/www/shared/shiny-es5.js new file mode 100644 index 0000000000..ee4458983d --- /dev/null +++ b/inst/www/shared/shiny-es5.js @@ -0,0 +1,6755 @@ +"use strict"; + +function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } + +function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } + +//--------------------------------------------------------------------- +// Source file: ../srcjs/_start.js +(function () { + var $ = jQuery; + var exports = window.Shiny = window.Shiny || {}; + exports.version = "../inst/www/shared/shiny-autoreload.js"; // Version number inserted by Grunt + + var origPushState = window.history.pushState; + + window.history.pushState = function () { + var result = origPushState.apply(this, arguments); + $(document).trigger("pushstate"); + return result; + }; + + $(document).on('submit', 'form:not([action])', function (e) { + e.preventDefault(); + }); //--------------------------------------------------------------------- + // Source file: ../srcjs/utils.js + + function escapeHTML(str) { + var escaped = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "/": "/" + }; + return str.replace(/[&<>'"\/]/g, function (m) { + return escaped[m]; + }); + } + + function randomId() { + return Math.floor(0x100000000 + Math.random() * 0xF00000000).toString(16); + } + + function strToBool(str) { + if (!str || !str.toLowerCase) return undefined; + + switch (str.toLowerCase()) { + case 'true': + return true; + + case 'false': + return false; + + default: + return undefined; + } + } // A wrapper for getComputedStyle that is compatible with older browsers. + // This is significantly faster than jQuery's .css() function. + + + function getStyle(el, styleProp) { + var x; + if (el.currentStyle) x = el.currentStyle[styleProp];else if (window.getComputedStyle) { + // getComputedStyle can return null when we're inside a hidden iframe on + // Firefox; don't attempt to retrieve style props in this case. + // https://bugzilla.mozilla.org/show_bug.cgi?id=548397 + var style = document.defaultView.getComputedStyle(el, null); + if (style) x = style.getPropertyValue(styleProp); + } + return x; + } // Convert a number to a string with leading zeros + + + function padZeros(n, digits) { + var str = n.toString(); + + while (str.length < digits) { + str = "0" + str; + } + + return str; + } // Round to a specified number of significant digits. + + + function roundSignif(x) { + var digits = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; + if (digits < 1) throw "Significant digits must be at least 1."; // This converts to a string and back to a number, which is inelegant, but + // is less prone to FP rounding error than an alternate method which used + // Math.round(). + + return parseFloat(x.toPrecision(digits)); + } // Take a string with format "YYYY-MM-DD" and return a Date object. + // IE8 and QTWebKit don't support YYYY-MM-DD, but they support YYYY/MM/DD + + + function parseDate(dateString) { + var date = new Date(dateString); + if (isNaN(date)) date = new Date(dateString.replace(/-/g, "/")); + return date; + } // Given a Date object, return a string in yyyy-mm-dd format, using the + // UTC date. This may be a day off from the date in the local time zone. + + + function formatDateUTC(date) { + if (date instanceof Date) { + return date.getUTCFullYear() + '-' + padZeros(date.getUTCMonth() + 1, 2) + '-' + padZeros(date.getUTCDate(), 2); + } else { + return null; + } + } // Given an element and a function(width, height), returns a function(). When + // the output function is called, it calls the input function with the offset + // width and height of the input element--but only if the size of the element + // is non-zero and the size is different than the last time the output + // function was called. + // + // Basically we are trying to filter out extraneous calls to func, so that + // when the window size changes or whatever, we don't run resize logic for + // elements that haven't actually changed size or aren't visible anyway. + + + function makeResizeFilter(el, func) { + var lastSize = {}; + return function () { + var size = { + w: el.offsetWidth, + h: el.offsetHeight + }; + if (size.w === 0 && size.h === 0) return; + if (size.w === lastSize.w && size.h === lastSize.h) return; + lastSize = size; + func(size.w, size.h); + }; + } + + var _BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder; + + function makeBlob(parts) { + // Browser compatibility is a mess right now. The code as written works in + // a variety of modern browsers, but sadly gives a deprecation warning + // message on the console in current versions (as of this writing) of + // Chrome. + // Safari 6.0 (8536.25) on Mac OS X 10.8.1: + // Has Blob constructor but it doesn't work with ArrayBufferView args + // Google Chrome 21.0.1180.81 on Xubuntu 12.04: + // Has Blob constructor, accepts ArrayBufferView args, accepts ArrayBuffer + // but with a deprecation warning message + // Firefox 15.0 on Xubuntu 12.04: + // Has Blob constructor, accepts both ArrayBuffer and ArrayBufferView args + // Chromium 18.0.1025.168 (Developer Build 134367 Linux) on Xubuntu 12.04: + // No Blob constructor. Has WebKitBlobBuilder. + try { + return new Blob(parts); + } catch (e) { + var blobBuilder = new _BlobBuilder(); + $.each(parts, function (i, part) { + blobBuilder.append(part); + }); + return blobBuilder.getBlob(); + } + } + + function pixelRatio() { + if (window.devicePixelRatio) { + return Math.round(window.devicePixelRatio * 100) / 100; + } else { + return 1; + } + } // Takes a string expression and returns a function that takes an argument. + // + // When the function is executed, it will evaluate that expression using + // "with" on the argument value, and return the result. + + + function scopeExprToFunc(expr) { + /*jshint evil: true */ + var expr_escaped = expr.replace(/[\\"']/g, '\\$&').replace(/\u0000/g, '\\0').replace(/\n/g, '\\n').replace(/\r/g, '\\r') // \b has a special meaning; need [\b] to match backspace char. + .replace(/[\b]/g, '\\b'); + + try { + var func = new Function("with (this) {\n try {\n return (".concat(expr, ");\n } catch (e) {\n console.error('Error evaluating expression: ").concat(expr_escaped, "');\n throw e;\n }\n }")); + } catch (e) { + console.error("Error parsing expression: " + expr); + throw e; + } + + return function (scope) { + return func.call(scope); + }; + } + + function asArray(value) { + if (value === null || value === undefined) return []; + if ($.isArray(value)) return value; + return [value]; + } // We need a stable sorting algorithm for ordering + // bindings by priority and insertion order. + + + function mergeSort(list, sortfunc) { + function merge(sortfunc, a, b) { + var ia = 0; + var ib = 0; + var sorted = []; + + while (ia < a.length && ib < b.length) { + if (sortfunc(a[ia], b[ib]) <= 0) { + sorted.push(a[ia++]); + } else { + sorted.push(b[ib++]); + } + } + + while (ia < a.length) { + sorted.push(a[ia++]); + } + + while (ib < b.length) { + sorted.push(b[ib++]); + } + + return sorted; + } // Don't mutate list argument + + + list = list.slice(0); + + for (var chunkSize = 1; chunkSize < list.length; chunkSize *= 2) { + for (var i = 0; i < list.length; i += chunkSize * 2) { + var listA = list.slice(i, i + chunkSize); + var listB = list.slice(i + chunkSize, i + chunkSize * 2); + var merged = merge(sortfunc, listA, listB); + var args = [i, merged.length]; + Array.prototype.push.apply(args, merged); + Array.prototype.splice.apply(list, args); + } + } + + return list; + } // Escape jQuery selector metacharacters: !"#$%&'()*+,./:;<=>?@[\]^`{|}~ + + + var $escape = exports.$escape = function (val) { + return val.replace(/([!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~])/g, '\\$1'); + }; // Maps a function over an object, preserving keys. Like the mapValues + // function from lodash. + + + function mapValues(obj, f) { + var newObj = {}; + + for (var key in obj) { + if (obj.hasOwnProperty(key)) newObj[key] = f(obj[key], key, obj); + } + + return newObj; + } // This is does the same as Number.isNaN, but that function unfortunately does + // not exist in any version of IE. + + + function isnan(x) { + return typeof x === 'number' && isNaN(x); + } // Binary equality function used by the equal function. + + + function _equal(x, y) { + if ($.type(x) === "object" && $.type(y) === "object") { + if (Object.keys(x).length !== Object.keys(y).length) return false; + + for (var prop in x) { + if (!y.hasOwnProperty(prop) || !_equal(x[prop], y[prop])) return false; + } + + return true; + } else if ($.type(x) === "array" && $.type(y) === "array") { + if (x.length !== y.length) return false; + + for (var i = 0; i < x.length; i++) { + if (!_equal(x[i], y[i])) return false; + } + + return true; + } else { + return x === y; + } + } // Structural or "deep" equality predicate. Tests two or more arguments for + // equality, traversing arrays and objects (as determined by $.type) as + // necessary. + // + // Objects other than objects and arrays are tested for equality using ===. + + + function equal() { + if (arguments.length < 2) throw new Error("equal requires at least two arguments."); + + for (var i = 0; i < arguments.length - 1; i++) { + if (!_equal(i < 0 || arguments.length <= i ? undefined : arguments[i], i + 1 < 0 || arguments.length <= i + 1 ? undefined : arguments[i + 1])) return false; + } + + return true; + } + + ; // Compare version strings like "1.0.1", "1.4-2". `op` must be a string like + // "==" or "<". + + exports.compareVersion = function (a, op, b) { + function versionParts(ver) { + return (ver + "").replace(/-/, ".").replace(/(\.0)+[^\.]*$/, "").split("."); + } + + function cmpVersion(a, b) { + a = versionParts(a); + b = versionParts(b); + var len = Math.min(a.length, b.length); + var cmp; + + for (var i = 0; i < len; i++) { + cmp = parseInt(a[i], 10) - parseInt(b[i], 10); + + if (cmp !== 0) { + return cmp; + } + } + + return a.length - b.length; + } + + var diff = cmpVersion(a, b); + if (op === "==") return diff === 0;else if (op === ">=") return diff >= 0;else if (op === ">") return diff > 0;else if (op === "<=") return diff <= 0;else if (op === "<") return diff < 0;else throw "Unknown operator: ".concat(op); + }; + + function updateLabel(labelTxt, labelNode) { + // Only update if label was specified in the update method + if (typeof labelTxt === "undefined") return; + + if (labelNode.length !== 1) { + throw new Error("labelNode must be of length 1"); + } // Should the label be empty? + + + var emptyLabel = $.isArray(labelTxt) && labelTxt.length === 0; + + if (emptyLabel) { + labelNode.addClass("shiny-label-null"); + } else { + labelNode.text(labelTxt); + labelNode.removeClass("shiny-label-null"); + } + } // Compute the color property of an a tag, scoped within the element + + + function getComputedLinkColor(el) { + var a = document.createElement("a"); + a.href = "/"; + var div = document.createElement("div"); + div.style.setProperty("position", "absolute", "important"); + div.style.setProperty("top", "-1000px", "important"); + div.style.setProperty("left", "0", "important"); + div.style.setProperty("width", "30px", "important"); + div.style.setProperty("height", "10px", "important"); + div.appendChild(a); + el.appendChild(div); + var linkColor = window.getComputedStyle(a).getPropertyValue("color"); + el.removeChild(div); + return linkColor; + } //--------------------------------------------------------------------- + // Source file: ../srcjs/browser.js + + + var browser = function () { + var isQt = false; // For easy handling of Qt quirks using CSS + + if (/\bQt\//.test(window.navigator.userAgent)) { + $(document.documentElement).addClass('qt'); + isQt = true; + } // Enable special treatment for Qt 5 quirks on Linux + + + if (/\bQt\/5/.test(window.navigator.userAgent) && /Linux/.test(window.navigator.userAgent)) { + $(document.documentElement).addClass('qt5'); + } // Detect IE and older (pre-Chromium) Edge + + + var ua = window.navigator.userAgent; + var isIE = /MSIE|Trident|Edge/.test(ua); + + function getIEVersion() { + var msie = ua.indexOf('MSIE '); + + if (isIE && msie > 0) { + // IE 10 or older => return version number + return parseInt(ua.substring(msie + 5, ua.indexOf('.', msie)), 10); + } + + var trident = ua.indexOf('Trident/'); + + if (trident > 0) { + // IE 11 => return version number + var rv = ua.indexOf('rv:'); + return parseInt(ua.substring(rv + 3, ua.indexOf('.', rv)), 10); + } + + return -1; + } + + return { + isQt: isQt, + isIE: isIE, + IEVersion: getIEVersion() + }; + }(); //--------------------------------------------------------------------- + // Source file: ../srcjs/input_rate.js + + + var Invoker = function Invoker(target, func) { + this.target = target; + this.func = func; + }; + + (function () { + this.normalCall = this.immediateCall = function () { + this.func.apply(this.target, arguments); + }; + }).call(Invoker.prototype); + + var Debouncer = function Debouncer(target, func, delayMs) { + this.target = target; + this.func = func; + this.delayMs = delayMs; + this.timerId = null; + this.args = null; + }; + + (function () { + this.normalCall = function () { + var self = this; + this.$clearTimer(); + this.args = arguments; + this.timerId = setTimeout(function () { + // IE8 doesn't reliably clear timeout, so this additional + // check is needed + if (self.timerId === null) return; + self.$clearTimer(); + self.$invoke(); + }, this.delayMs); + }; + + this.immediateCall = function () { + this.$clearTimer(); + this.args = arguments; + this.$invoke(); + }; + + this.isPending = function () { + return this.timerId !== null; + }; + + this.$clearTimer = function () { + if (this.timerId !== null) { + clearTimeout(this.timerId); + this.timerId = null; + } + }; + + this.$invoke = function () { + this.func.apply(this.target, this.args); + this.args = null; + }; + }).call(Debouncer.prototype); + + var Throttler = function Throttler(target, func, delayMs) { + this.target = target; + this.func = func; + this.delayMs = delayMs; + this.timerId = null; + this.args = null; + }; + + (function () { + this.normalCall = function () { + var self = this; + this.args = arguments; + + if (this.timerId === null) { + this.$invoke(); + this.timerId = setTimeout(function () { + // IE8 doesn't reliably clear timeout, so this additional + // check is needed + if (self.timerId === null) return; + self.$clearTimer(); + if (self.args) self.normalCall.apply(self, self.args); + }, this.delayMs); + } + }; + + this.immediateCall = function () { + this.$clearTimer(); + this.args = arguments; + this.$invoke(); + }; + + this.isPending = function () { + return this.timerId !== null; + }; + + this.$clearTimer = function () { + if (this.timerId !== null) { + clearTimeout(this.timerId); + this.timerId = null; + } + }; + + this.$invoke = function () { + this.func.apply(this.target, this.args); + this.args = null; + }; + }).call(Throttler.prototype); // Returns a debounced version of the given function. + // Debouncing means that when the function is invoked, + // there is a delay of `threshold` milliseconds before + // it is actually executed, and if the function is + // invoked again before that threshold has elapsed then + // the clock starts over. + // + // For example, if a function is debounced with a + // threshold of 1000ms, then calling it 17 times at + // 900ms intervals will result in a single execution + // of the underlying function, 1000ms after the 17th + // call. + + function debounce(threshold, func) { + var timerId = null; + var self, args; + return function () { + self = this; + args = arguments; + + if (timerId !== null) { + clearTimeout(timerId); + timerId = null; + } + + timerId = setTimeout(function () { + // IE8 doesn't reliably clear timeout, so this additional + // check is needed + if (timerId === null) return; + timerId = null; + func.apply(self, args); + }, threshold); + }; + } // Returns a throttled version of the given function. + // Throttling means that the underlying function will + // be executed no more than once every `threshold` + // milliseconds. + // + // For example, if a function is throttled with a + // threshold of 1000ms, then calling it 17 times at + // 900ms intervals will result in something like 15 + // or 16 executions of the underlying function. + // eslint-disable-next-line no-unused-vars + + + function throttle(threshold, func) { + var executionPending = false; + var timerId = null; + var self, args; + + function throttled() { + self = null; + args = null; + + if (timerId === null) { + // Haven't seen a call recently. Execute now and + // start a timer to buffer any subsequent calls. + timerId = setTimeout(function () { + // When time expires, clear the timer; and if + // there has been a call in the meantime, repeat. + timerId = null; + + if (executionPending) { + executionPending = false; + throttled.apply(self, args); + } + }, threshold); + func.apply(this, arguments); + } else { + // Something executed recently. Don't do anything + // except set up target/arguments to be called later + executionPending = true; + self = this; + args = arguments; + } + } + + return throttled; + } // Schedules data to be sent to shinyapp at the next setTimeout(0). + // Batches multiple input calls into one websocket message. + + + var InputBatchSender = function InputBatchSender(shinyapp) { + this.shinyapp = shinyapp; + this.timerId = null; + this.pendingData = {}; + this.reentrant = false; + this.lastChanceCallback = []; + }; + + (function () { + this.setInput = function (nameType, value, opts) { + this.pendingData[nameType] = value; + + if (!this.reentrant) { + if (opts.priority === "event") { + this.$sendNow(); + } else if (!this.timerId) { + this.timerId = setTimeout(this.$sendNow.bind(this), 0); + } + } + }; + + this.$sendNow = function () { + if (this.reentrant) { + console.trace("Unexpected reentrancy in InputBatchSender!"); + } + + this.reentrant = true; + + try { + this.timerId = null; + $.each(this.lastChanceCallback, function (i, callback) { + callback(); + }); + var currentData = this.pendingData; + this.pendingData = {}; + this.shinyapp.sendInput(currentData); + } finally { + this.reentrant = false; + } + }; + }).call(InputBatchSender.prototype); + + var InputNoResendDecorator = function InputNoResendDecorator(target, initialValues) { + this.target = target; + this.lastSentValues = this.reset(initialValues); + }; + + (function () { + this.setInput = function (nameType, value, opts) { + var _splitInputNameType = splitInputNameType(nameType), + inputName = _splitInputNameType.name, + inputType = _splitInputNameType.inputType; + + var jsonValue = JSON.stringify(value); + + if (opts.priority !== "event" && this.lastSentValues[inputName] && this.lastSentValues[inputName].jsonValue === jsonValue && this.lastSentValues[inputName].inputType === inputType) { + return; + } + + this.lastSentValues[inputName] = { + jsonValue: jsonValue, + inputType: inputType + }; + this.target.setInput(nameType, value, opts); + }; + + this.reset = function () { + var values = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + // Given an object with flat name-value format: + // { x: "abc", "y.shiny.number": 123 } + // Create an object in cache format and save it: + // { x: { jsonValue: '"abc"', inputType: "" }, + // y: { jsonValue: "123", inputType: "shiny.number" } } + var cacheValues = {}; + + for (var inputName in values) { + if (values.hasOwnProperty(inputName)) { + var _splitInputNameType2 = splitInputNameType(inputName), + name = _splitInputNameType2.name, + inputType = _splitInputNameType2.inputType; + + cacheValues[name] = { + jsonValue: JSON.stringify(values[inputName]), + inputType: inputType + }; + } + } + + this.lastSentValues = cacheValues; + }; + + this.forget = function (name) { + delete this.lastSentValues[name]; + }; + }).call(InputNoResendDecorator.prototype); + + var InputEventDecorator = function InputEventDecorator(target) { + this.target = target; + }; + + (function () { + this.setInput = function (nameType, value, opts) { + var evt = jQuery.Event("shiny:inputchanged"); + var input = splitInputNameType(nameType); + evt.name = input.name; + evt.inputType = input.inputType; + evt.value = value; + evt.binding = opts.binding; + evt.el = opts.el; + evt.priority = opts.priority; + $(opts.el).trigger(evt); + + if (!evt.isDefaultPrevented()) { + var name = evt.name; + if (evt.inputType !== '') name += ':' + evt.inputType; // Most opts aren't passed along to lower levels in the input decorator + // stack. + + this.target.setInput(name, evt.value, { + priority: opts.priority + }); + } + }; + }).call(InputEventDecorator.prototype); + + var InputRateDecorator = function InputRateDecorator(target) { + this.target = target; + this.inputRatePolicies = {}; + }; + + (function () { + // Note that the first argument of setInput() and setRatePolicy() + // are passed both the input name (i.e., inputId) and type. + // https://github.com/rstudio/shiny/blob/67d3a/srcjs/init_shiny.js#L111-L126 + // However, $ensureInit() and $doSetInput() are meant to be passed just + // the input name (i.e., inputId), which is why we distinguish between + // nameType and name. + this.setInput = function (nameType, value, opts) { + var _splitInputNameType3 = splitInputNameType(nameType), + inputName = _splitInputNameType3.name; + + this.$ensureInit(inputName); + if (opts.priority !== "deferred") this.inputRatePolicies[inputName].immediateCall(nameType, value, opts);else this.inputRatePolicies[inputName].normalCall(nameType, value, opts); + }; + + this.setRatePolicy = function (nameType, mode, millis) { + var _splitInputNameType4 = splitInputNameType(nameType), + inputName = _splitInputNameType4.name; + + if (mode === 'direct') { + this.inputRatePolicies[inputName] = new Invoker(this, this.$doSetInput); + } else if (mode === 'debounce') { + this.inputRatePolicies[inputName] = new Debouncer(this, this.$doSetInput, millis); + } else if (mode === 'throttle') { + this.inputRatePolicies[inputName] = new Throttler(this, this.$doSetInput, millis); + } + }; + + this.$ensureInit = function (name) { + if (!(name in this.inputRatePolicies)) this.setRatePolicy(name, 'direct'); + }; + + this.$doSetInput = function (nameType, value, opts) { + this.target.setInput(nameType, value, opts); + }; + }).call(InputRateDecorator.prototype); + + var InputDeferDecorator = function InputDeferDecorator(target) { + this.target = target; + this.pendingInput = {}; + }; + + (function () { + this.setInput = function (nameType, value, opts) { + if (/^\./.test(nameType)) this.target.setInput(nameType, value, opts);else this.pendingInput[nameType] = { + value: value, + opts: opts + }; + }; + + this.submit = function () { + for (var nameType in this.pendingInput) { + if (this.pendingInput.hasOwnProperty(nameType)) { + var _this$pendingInput$na = this.pendingInput[nameType], + value = _this$pendingInput$na.value, + opts = _this$pendingInput$na.opts; + this.target.setInput(nameType, value, opts); + } + } + }; + }).call(InputDeferDecorator.prototype); + + var InputValidateDecorator = function InputValidateDecorator(target) { + this.target = target; + }; + + (function () { + this.setInput = function (nameType, value, opts) { + if (!nameType) throw "Can't set input with empty name."; + opts = addDefaultInputOpts(opts); + this.target.setInput(nameType, value, opts); + }; + }).call(InputValidateDecorator.prototype); // Merge opts with defaults, and return a new object. + + function addDefaultInputOpts(opts) { + opts = $.extend({ + priority: "immediate", + binding: null, + el: null + }, opts); + + if (opts && typeof opts.priority !== "undefined") { + switch (opts.priority) { + case "deferred": + case "immediate": + case "event": + break; + + default: + throw new Error("Unexpected input value mode: '" + opts.priority + "'"); + } + } + + return opts; + } + + function splitInputNameType(nameType) { + var name2 = nameType.split(':'); + return { + name: name2[0], + inputType: name2.length > 1 ? name2[1] : '' + }; + } //--------------------------------------------------------------------- + // Source file: ../srcjs/shinyapp.js + + + var ShinyApp = function ShinyApp() { + this.$socket = null; // Cached input values + + this.$inputValues = {}; // Input values at initialization (and reconnect) + + this.$initialInput = {}; // Output bindings + + this.$bindings = {}; // Cached values/errors + + this.$values = {}; + this.$errors = {}; // Conditional bindings (show/hide element based on expression) + + this.$conditionals = {}; + this.$pendingMessages = []; + this.$activeRequests = {}; + this.$nextRequestId = 0; + this.$allowReconnect = false; + }; + + (function () { + this.connect = function (initialInput) { + if (this.$socket) throw "Connect was already called on this application object"; + this.$socket = this.createSocket(); + this.$initialInput = initialInput; + $.extend(this.$inputValues, initialInput); + this.$updateConditionals(); + }; + + this.isConnected = function () { + return !!this.$socket; + }; + + var scheduledReconnect = null; + + this.reconnect = function () { + // This function can be invoked directly even if there's a scheduled + // reconnect, so be sure to clear any such scheduled reconnects. + clearTimeout(scheduledReconnect); + if (this.isConnected()) throw "Attempted to reconnect, but already connected."; + this.$socket = this.createSocket(); + this.$initialInput = $.extend({}, this.$inputValues); + this.$updateConditionals(); + }; + + this.createSocket = function () { + var self = this; + + var createSocketFunc = exports.createSocket || function () { + var protocol = 'ws:'; + if (window.location.protocol === 'https:') protocol = 'wss:'; + var defaultPath = window.location.pathname; // some older WebKit browsers return the pathname already decoded; + // if we find invalid URL characters in the path, encode them + + if (!/^([$#!&-;=?-[\]_a-z~]|%[0-9a-fA-F]{2})+$/.test(defaultPath)) { + defaultPath = encodeURI(defaultPath); // Bizarrely, QtWebKit requires us to encode these characters *twice* + + if (browser.isQt) { + defaultPath = encodeURI(defaultPath); + } + } + + if (!/\/$/.test(defaultPath)) defaultPath += '/'; + defaultPath += 'websocket/'; + var ws = new WebSocket(protocol + '//' + window.location.host + defaultPath); + ws.binaryType = 'arraybuffer'; + return ws; + }; + + var socket = createSocketFunc(); + var hasOpened = false; + + socket.onopen = function () { + hasOpened = true; + $(document).trigger({ + type: 'shiny:connected', + socket: socket + }); + self.onConnected(); + socket.send(JSON.stringify({ + method: 'init', + data: self.$initialInput + })); + + while (self.$pendingMessages.length) { + var msg = self.$pendingMessages.shift(); + socket.send(msg); + } + }; + + socket.onmessage = function (e) { + self.dispatchMessage(e.data); + }; // Called when a successfully-opened websocket is closed, or when an + // attempt to open a connection fails. + + + socket.onclose = function () { + // These things are needed only if we've successfully opened the + // websocket. + if (hasOpened) { + $(document).trigger({ + type: 'shiny:disconnected', + socket: socket + }); + self.$notifyDisconnected(); + } + + self.onDisconnected(); // Must be run before self.$removeSocket() + + self.$removeSocket(); + }; + + return socket; + }; + + this.sendInput = function (values) { + var msg = JSON.stringify({ + method: 'update', + data: values + }); + this.$sendMsg(msg); + $.extend(this.$inputValues, values); + this.$updateConditionals(); + }; + + this.$notifyDisconnected = function () { + if (window.parent) { + window.parent.postMessage("disconnected", "*"); + } + }; + + this.$removeSocket = function () { + this.$socket = null; + }; + + this.$scheduleReconnect = function (delay) { + var self = this; + scheduledReconnect = setTimeout(function () { + self.reconnect(); + }, delay); + }; // How long should we wait before trying the next reconnection? + // The delay will increase with subsequent attempts. + // .next: Return the time to wait for next connection, and increment counter. + // .reset: Reset the attempt counter. + + + var reconnectDelay = function () { + var attempts = 0; // Time to wait before each reconnection attempt. If we go through all of + // these values, repeated use the last one. Add 500ms to each one so that + // in the last 0.5s, it shows "..." + + var delays = [1500, 1500, 2500, 2500, 5500, 5500, 10500]; + return { + next: function next() { + var i = attempts; // Instead of going off the end, use the last one + + if (i >= delays.length) { + i = delays.length - 1; + } + + attempts++; + return delays[i]; + }, + reset: function reset() { + attempts = 0; + } + }; + }(); + + this.onDisconnected = function () { + // Add gray-out overlay, if not already present + var $overlay = $('#shiny-disconnected-overlay'); + + if ($overlay.length === 0) { + $(document.body).append('
'); + } // To try a reconnect, both the app (this.$allowReconnect) and the + // server (this.$socket.allowReconnect) must allow reconnections, or + // session$allowReconnect("force") was called. The "force" option should + // only be used for testing. + + + if (this.$allowReconnect === true && this.$socket.allowReconnect === true || this.$allowReconnect === "force") { + var delay = reconnectDelay.next(); + exports.showReconnectDialog(delay); + this.$scheduleReconnect(delay); + } + }; + + this.onConnected = function () { + $('#shiny-disconnected-overlay').remove(); + exports.hideReconnectDialog(); + reconnectDelay.reset(); + }; // NB: Including blobs will cause IE to break! + // TODO: Make blobs work with Internet Explorer + // + // Websocket messages are normally one-way--i.e. the client passes a + // message to the server but there is no way for the server to provide + // a response to that specific message. makeRequest provides a way to + // do asynchronous RPC over websocket. Each request has a method name + // and arguments, plus optionally one or more binary blobs can be + // included as well. The request is tagged with a unique number that + // the server will use to label the corresponding response. + // + // @param method A string that tells the server what logic to run. + // @param args An array of objects that should also be passed to the + // server in JSON-ified form. + // @param onSuccess A function that will be called back if the server + // responds with success. If the server provides a value in the + // response, the function will be called with it as the only argument. + // @param onError A function that will be called back if the server + // responds with error, or if the request fails for any other reason. + // The parameter to onError will be a string describing the error. + // @param blobs Optionally, an array of Blob, ArrayBuffer, or string + // objects that will be made available to the server as part of the + // request. Strings will be encoded using UTF-8. + + + this.makeRequest = function (method, args, onSuccess, onError, blobs) { + var requestId = this.$nextRequestId; + + while (this.$activeRequests[requestId]) { + requestId = (requestId + 1) % 1000000000; + } + + this.$nextRequestId = requestId + 1; + this.$activeRequests[requestId] = { + onSuccess: onSuccess, + onError: onError + }; + var msg = JSON.stringify({ + method: method, + args: args, + tag: requestId + }); + + if (blobs) { + // We have binary data to transfer; form a different kind of packet. + // Start with a 4-byte signature, then for each blob, emit 4 bytes for + // the length followed by the blob. The json payload is UTF-8 encoded + // and used as the first blob. + var uint32_to_buf = function uint32_to_buf(val) { + var buffer = new ArrayBuffer(4); + var view = new DataView(buffer); + view.setUint32(0, val, true); // little-endian + + return buffer; + }; + + var payload = []; + payload.push(uint32_to_buf(0x01020202)); // signature + + var jsonBuf = makeBlob([msg]); + payload.push(uint32_to_buf(jsonBuf.size)); + payload.push(jsonBuf); + + for (var i = 0; i < blobs.length; i++) { + payload.push(uint32_to_buf(blobs[i].byteLength || blobs[i].size || 0)); + payload.push(blobs[i]); + } + + msg = makeBlob(payload); + } + + this.$sendMsg(msg); + }; + + this.$sendMsg = function (msg) { + if (!this.$socket.readyState) { + this.$pendingMessages.push(msg); + } else { + this.$socket.send(msg); + } + }; + + this.receiveError = function (name, error) { + if (this.$errors[name] === error) return; + this.$errors[name] = error; + delete this.$values[name]; + var binding = this.$bindings[name]; + var evt = jQuery.Event('shiny:error'); + evt.name = name; + evt.error = error; + evt.binding = binding; + $(binding ? binding.el : document).trigger(evt); + + if (!evt.isDefaultPrevented() && binding && binding.onValueError) { + binding.onValueError(evt.error); + } + }; + + this.receiveOutput = function (name, value) { + var binding = this.$bindings[name]; + var evt = jQuery.Event('shiny:value'); + evt.name = name; + evt.value = value; + evt.binding = binding; + + if (this.$values[name] === value) { + $(binding ? binding.el : document).trigger(evt); + return undefined; + } + + this.$values[name] = value; + delete this.$errors[name]; + $(binding ? binding.el : document).trigger(evt); + + if (!evt.isDefaultPrevented() && binding) { + binding.onValueChange(evt.value); + } + + return value; + }; + + this.bindOutput = function (id, binding) { + if (!id) throw "Can't bind an element with no ID"; + if (this.$bindings[id]) throw "Duplicate binding for ID " + id; + this.$bindings[id] = binding; + if (this.$values[id] !== undefined) binding.onValueChange(this.$values[id]);else if (this.$errors[id] !== undefined) binding.onValueError(this.$errors[id]); + return binding; + }; + + this.unbindOutput = function (id, binding) { + if (this.$bindings[id] === binding) { + delete this.$bindings[id]; + return true; + } else { + return false; + } + }; // Narrows a scopeComponent -- an input or output object -- to one constrained + // by nsPrefix. Returns a new object with keys removed and renamed as + // necessary. + + + function narrowScopeComponent(scopeComponent, nsPrefix) { + return Object.keys(scopeComponent).filter(function (k) { + return k.indexOf(nsPrefix) === 0; + }).map(function (k) { + return _defineProperty({}, k.substring(nsPrefix.length), scopeComponent[k]); + }).reduce(function (obj, pair) { + return $.extend(obj, pair); + }, {}); + } // Narrows a scope -- an object with input and output "subComponents" -- to + // one constrained by the nsPrefix string. + // + // If nsPrefix is null or empty, returns scope without modification. + // + // Otherwise, returns a new object with keys in subComponents removed and + // renamed as necessary. + + + function narrowScope(scope, nsPrefix) { + return nsPrefix ? { + input: narrowScopeComponent(scope.input, nsPrefix), + output: narrowScopeComponent(scope.output, nsPrefix) + } : scope; + } + + this.$updateConditionals = function () { + $(document).trigger({ + type: 'shiny:conditional' + }); + var inputs = {}; // Input keys use "name:type" format; we don't want the user to + // have to know about the type suffix when referring to inputs. + + for (var name in this.$inputValues) { + if (this.$inputValues.hasOwnProperty(name)) { + var shortName = name.replace(/:.*/, ''); + inputs[shortName] = this.$inputValues[name]; + } + } + + var scope = { + input: inputs, + output: this.$values + }; + var conditionals = $(document).find('[data-display-if]'); + + for (var i = 0; i < conditionals.length; i++) { + var el = $(conditionals[i]); + var condFunc = el.data('data-display-if-func'); + + if (!condFunc) { + var condExpr = el.attr('data-display-if'); + condFunc = scopeExprToFunc(condExpr); + el.data('data-display-if-func', condFunc); + } + + var nsPrefix = el.attr('data-ns-prefix'); + var nsScope = narrowScope(scope, nsPrefix); + var show = condFunc(nsScope); + var showing = el.css("display") !== "none"; + + if (show !== showing) { + if (show) { + el.trigger('show'); + el.show(); + el.trigger('shown'); + } else { + el.trigger('hide'); + el.hide(); + el.trigger('hidden'); + } + } + } + }; // Message handler management functions ================================= + // Records insertion order of handlers. Maps number to name. This is so + // we can dispatch messages to handlers in the order that handlers were + // added. + + + var messageHandlerOrder = []; // Keep track of handlers by name. Maps name to handler function. + + var messageHandlers = {}; // Two categories of message handlers: those that are from Shiny, and those + // that are added by the user. The Shiny ones handle messages in + // msgObj.values, msgObj.errors, and so on. The user ones handle messages + // in msgObj.custom.foo and msgObj.custom.bar. + + var customMessageHandlerOrder = []; + var customMessageHandlers = {}; // Adds Shiny (internal) message handler + + function addMessageHandler(type, handler) { + if (messageHandlers[type]) { + throw 'handler for message of type "' + type + '" already added.'; + } + + if (typeof handler !== 'function') { + throw 'handler must be a function.'; + } + + if (handler.length !== 1) { + throw 'handler must be a function that takes one argument.'; + } + + messageHandlerOrder.push(type); + messageHandlers[type] = handler; + } // Adds custom message handler - this one is exposed to the user + + + function addCustomMessageHandler(type, handler) { + // Remove any previously defined handlers so that only the most recent one + // will be called + if (customMessageHandlers[type]) { + var typeIdx = customMessageHandlerOrder.indexOf(type); + + if (typeIdx !== -1) { + customMessageHandlerOrder.splice(typeIdx, 1); + delete customMessageHandlers[type]; + } + } + + if (typeof handler !== 'function') { + throw 'handler must be a function.'; + } + + if (handler.length !== 1) { + throw 'handler must be a function that takes one argument.'; + } + + customMessageHandlerOrder.push(type); + customMessageHandlers[type] = handler; + } + + exports.addCustomMessageHandler = addCustomMessageHandler; + + this.dispatchMessage = function (data) { + var msgObj = {}; + + if (typeof data === "string") { + msgObj = JSON.parse(data); + } else { + // data is arraybuffer + var len = new DataView(data, 0, 1).getUint8(0); + var typedv = new DataView(data, 1, len); + var typebuf = []; + + for (var i = 0; i < len; i++) { + typebuf.push(String.fromCharCode(typedv.getUint8(i))); + } + + var type = typebuf.join(""); + data = data.slice(len + 1); + msgObj.custom = {}; + msgObj.custom[type] = data; + } + + var evt = jQuery.Event('shiny:message'); + evt.message = msgObj; + $(document).trigger(evt); + if (evt.isDefaultPrevented()) return; // Send msgObj.foo and msgObj.bar to appropriate handlers + + this._sendMessagesToHandlers(evt.message, messageHandlers, messageHandlerOrder); + + this.$updateConditionals(); + }; // A function for sending messages to the appropriate handlers. + // - msgObj: the object containing messages, with format {msgObj.foo, msObj.bar + + + this._sendMessagesToHandlers = function (msgObj, handlers, handlerOrder) { + // Dispatch messages to handlers, if handler is present + for (var i = 0; i < handlerOrder.length; i++) { + var msgType = handlerOrder[i]; + + if (msgObj.hasOwnProperty(msgType)) { + // Execute each handler with 'this' referring to the present value of + // 'this' + handlers[msgType].call(this, msgObj[msgType]); + } + } + }; // Message handlers ===================================================== + + + addMessageHandler('values', function (message) { + for (var name in this.$bindings) { + if (this.$bindings.hasOwnProperty(name)) this.$bindings[name].showProgress(false); + } + + for (var key in message) { + if (message.hasOwnProperty(key)) this.receiveOutput(key, message[key]); + } + }); + addMessageHandler('errors', function (message) { + for (var key in message) { + if (message.hasOwnProperty(key)) this.receiveError(key, message[key]); + } + }); + addMessageHandler('inputMessages', function (message) { + // inputMessages should be an array + for (var i = 0; i < message.length; i++) { + var $obj = $('.shiny-bound-input#' + $escape(message[i].id)); + var inputBinding = $obj.data('shiny-input-binding'); // Dispatch the message to the appropriate input object + + if ($obj.length > 0) { + if (!$obj.attr("aria-live")) $obj.attr("aria-live", "polite"); + var el = $obj[0]; + var evt = jQuery.Event('shiny:updateinput'); + evt.message = message[i].message; + evt.binding = inputBinding; + $(el).trigger(evt); + if (!evt.isDefaultPrevented()) inputBinding.receiveMessage(el, evt.message); + } + } + }); + addMessageHandler('javascript', function (message) { + /*jshint evil: true */ + eval(message); + }); + addMessageHandler('console', function (message) { + for (var i = 0; i < message.length; i++) { + if (console.log) console.log(message[i]); + } + }); + addMessageHandler('progress', function (message) { + if (message.type && message.message) { + var handler = progressHandlers[message.type]; + if (handler) handler.call(this, message.message); + } + }); + addMessageHandler('notification', function (message) { + if (message.type === 'show') exports.notifications.show(message.message);else if (message.type === 'remove') exports.notifications.remove(message.message);else throw 'Unkown notification type: ' + message.type; + }); + addMessageHandler('modal', function (message) { + if (message.type === 'show') exports.modal.show(message.message);else if (message.type === 'remove') exports.modal.remove(); // For 'remove', message content isn't used + else throw 'Unkown modal type: ' + message.type; + }); + addMessageHandler('response', function (message) { + var requestId = message.tag; + var request = this.$activeRequests[requestId]; + + if (request) { + delete this.$activeRequests[requestId]; + if ('value' in message) request.onSuccess(message.value);else request.onError(message.error); + } + }); + addMessageHandler('allowReconnect', function (message) { + if (message === true || message === false || message === "force") { + this.$allowReconnect = message; + } else { + throw "Invalid value for allowReconnect: " + message; + } + }); + addMessageHandler('custom', function (message) { + // For old-style custom messages - should deprecate and migrate to new + // method + if (exports.oncustommessage) { + exports.oncustommessage(message); + } // Send messages.foo and messages.bar to appropriate handlers + + + this._sendMessagesToHandlers(message, customMessageHandlers, customMessageHandlerOrder); + }); + addMessageHandler('config', function (message) { + this.config = { + workerId: message.workerId, + sessionId: message.sessionId + }; + if (message.user) exports.user = message.user; + $(document).trigger('shiny:sessioninitialized'); + }); + addMessageHandler('busy', function (message) { + if (message === 'busy') { + $(document.documentElement).addClass('shiny-busy'); + $(document).trigger('shiny:busy'); + } else if (message === 'idle') { + $(document.documentElement).removeClass('shiny-busy'); + $(document).trigger('shiny:idle'); + } + }); + addMessageHandler('recalculating', function (message) { + if (message.hasOwnProperty('name') && message.hasOwnProperty('status')) { + var binding = this.$bindings[message.name]; + $(binding ? binding.el : null).trigger({ + type: 'shiny:' + message.status + }); + } + }); + addMessageHandler('reload', function (message) { + window.location.reload(); + }); + addMessageHandler('shiny-insert-ui', function (message) { + var targets = $(message.selector); + + if (targets.length === 0) { + // render the HTML and deps to a null target, so + // the side-effect of rendering the deps, singletons, + // and still occur + console.warn('The selector you chose ("' + message.selector + '") could not be found in the DOM.'); + exports.renderHtml(message.content.html, $([]), message.content.deps); + } else { + targets.each(function (i, target) { + exports.renderContent(target, message.content, message.where); + return message.multiple; + }); + } + }); + addMessageHandler('shiny-remove-ui', function (message) { + var els = $(message.selector); + els.each(function (i, el) { + exports.unbindAll(el, true); + $(el).remove(); // If `multiple` is false, returning false terminates the function + // and no other elements are removed; if `multiple` is true, + // returning true continues removing all remaining elements. + + return message.multiple; + }); + }); + addMessageHandler('frozen', function (message) { + for (var i = 0; i < message.ids.length; i++) { + exports.forgetLastInputValue(message.ids[i]); + } + }); + + function getTabset(id) { + var $tabset = $("#" + $escape(id)); + if ($tabset.length === 0) throw "There is no tabsetPanel (or navbarPage or navlistPanel) " + "with id equal to '" + id + "'"; + return $tabset; + } + + function getTabContent($tabset) { + var tabsetId = $tabset.attr("data-tabsetid"); + var $tabContent = $("div.tab-content[data-tabsetid='" + $escape(tabsetId) + "']"); + return $tabContent; + } + + function getTargetTabs($tabset, $tabContent, target) { + var dataValue = "[data-value='" + $escape(target) + "']"; + var $aTag = $tabset.find("a" + dataValue); + var $liTag = $aTag.parent(); + + if ($liTag.length === 0) { + throw "There is no tabPanel (or navbarMenu) with value" + " (or menuName) equal to '" + target + "'"; + } + + var $liTags = []; + var $divTags = []; + + if ($aTag.attr("data-toggle") === "dropdown") { + // dropdown + var $dropdownTabset = $aTag.find("+ ul.dropdown-menu"); + var dropdownId = $dropdownTabset.attr("data-tabsetid"); + var $dropdownLiTags = $dropdownTabset.find("a[data-toggle='tab']").parent("li"); + $dropdownLiTags.each(function (i, el) { + $liTags.push($(el)); + }); + var selector = "div.tab-pane[id^='tab-" + $escape(dropdownId) + "']"; + var $dropdownDivs = $tabContent.find(selector); + $dropdownDivs.each(function (i, el) { + $divTags.push($(el)); + }); + } else { + // regular tab + $divTags.push($tabContent.find("div" + dataValue)); + } + + return { + $liTag: $liTag, + $liTags: $liTags, + $divTags: $divTags + }; + } + + addMessageHandler("shiny-insert-tab", function (message) { + var $parentTabset = getTabset(message.inputId); + var $tabset = $parentTabset; + var $tabContent = getTabContent($tabset); + var tabsetId = $parentTabset.attr("data-tabsetid"); + var $divTag = $(message.divTag.html); + var $liTag = $(message.liTag.html); + var $aTag = $liTag.find("> a"); // Unless the item is being prepended/appended, the target tab + // must be provided + + var target = null; + var $targetLiTag = null; + + if (message.target !== null) { + target = getTargetTabs($tabset, $tabContent, message.target); + $targetLiTag = target.$liTag; + } // If the item is to be placed inside a navbarMenu (dropdown), + // change the value of $tabset from the parent's ul tag to the + // dropdown's ul tag + + + var dropdown = getDropdown(); + + if (dropdown !== null) { + if ($aTag.attr("data-toggle") === "dropdown") throw "Cannot insert a navbarMenu inside another one"; + $tabset = dropdown.$tabset; + tabsetId = dropdown.id; + } // For regular tab items, fix the href (of the li > a tag) + // and the id (of the div tag). This does not apply to plain + // text items (which function as dividers and headers inside + // navbarMenus) and whole navbarMenus (since those get + // constructed from scratch on the R side and therefore + // there are no ids that need matching) + + + if ($aTag.attr("data-toggle") === "tab") { + var index = getTabIndex($tabset, tabsetId); + var tabId = "tab-" + tabsetId + "-" + index; + $liTag.find("> a").attr("href", "#" + tabId); + $divTag.attr("id", tabId); + } // actually insert the item into the right place + + + if (message.position === "before") { + if ($targetLiTag) { + $targetLiTag.before($liTag); + } else { + $tabset.append($liTag); + } + } else if (message.position === "after") { + if ($targetLiTag) { + $targetLiTag.after($liTag); + } else { + $tabset.prepend($liTag); + } + } + + exports.renderContent($liTag[0], { + html: $liTag.html(), + deps: message.liTag.deps + }); // jcheng 2017-07-28: This next part might look a little insane versus the + // more obvious `$tabContent.append($divTag);`, but there's a method to the + // madness. + // + // 1) We need to load the dependencies, and this needs to happen before + // any scripts in $divTag get a chance to run. + // 2) The scripts in $divTag need to run only once. + // 3) The contents of $divTag need to be sent through renderContent so that + // singletons may be registered and/or obeyed, and so that inputs/outputs + // may be bound. + // + // Add to these constraints these facts: + // + // A) The (non-jQuery) DOM manipulation functions don't cause scripts to + // run, but the jQuery functions all do. + // B) renderContent must be called on an element that's attached to the + // document. + // C) $divTag may be of length > 1 (e.g. navbarMenu). I also noticed text + // elements consisting of just "\n" being included in the nodeset of + // $divTag. + // D) renderContent has a bug where only position "replace" (the default) + // uses the jQuery functions, so other positions like "beforeend" will + // prevent child script tags from running. + // + // In theory the same problem exists for $liTag but since that content is + // much less likely to include arbitrary scripts, we're skipping it. + // + // This code could be nicer if we didn't use renderContent, but rather the + // lower-level functions that renderContent uses. Like if we pre-process + // the value of message.divTag.html for singletons, we could do that, then + // render dependencies, then do $tabContent.append($divTag). + + exports.renderContent($tabContent[0], { + html: "", + deps: message.divTag.deps + }, "beforeend"); + $divTag.get().forEach(function (el) { + // Must not use jQuery for appending el to the doc, we don't want any + // scripts to run (since they will run when renderContent takes a crack). + $tabContent[0].appendChild(el); // If `el` itself is a script tag, this approach won't work (the script + // won't be run), since we're only sending innerHTML through renderContent + // and not the whole tag. That's fine in this case because we control the + // R code that generates this HTML, and we know that the element is not + // a script tag. + + exports.renderContent(el, el.innerHTML || el.textContent); + }); + + if (message.select) { + $liTag.find("a").tab("show"); + } + /* Barbara -- August 2017 + Note: until now, the number of tabs in a tabsetPanel (or navbarPage + or navlistPanel) was always fixed. So, an easy way to give an id to + a tab was simply incrementing a counter. (Just like it was easy to + give a random 4-digit number to identify the tabsetPanel). Now that + we're introducing dynamic tabs, we must retrieve these numbers and + fix the dummy id given to the tab in the R side -- there, we always + set the tab id (counter dummy) to "id" and the tabset id to "tsid") + */ + + + function getTabIndex($tabset, tabsetId) { + // The 0 is to ensure this works for empty tabsetPanels as well + var existingTabIds = [0]; // loop through all existing tabs, find the one with highest id + // (since this is based on a numeric counter), and increment + + $tabset.find("> li").each(function () { + var $tab = $(this).find("> a[data-toggle='tab']"); + + if ($tab.length > 0) { + // remove leading url if it exists. (copy of bootstrap url stripper) + var href = $tab.attr("href").replace(/.*(?=#[^\s]+$)/, ''); // remove tab id to get the index + + var index = href.replace("#tab-" + tabsetId + "-", ""); + existingTabIds.push(Number(index)); + } + }); + return Math.max.apply(null, existingTabIds) + 1; + } // Finds out if the item will be placed inside a navbarMenu + // (dropdown). If so, returns the dropdown tabset (ul tag) + // and the dropdown tabsetid (to be used to fix the tab ID) + + + function getDropdown() { + if (message.menuName !== null) { + // menuName is only provided if the user wants to prepend + // or append an item inside a navbarMenu (dropdown) + var $dropdownATag = $("a.dropdown-toggle[data-value='" + $escape(message.menuName) + "']"); + + if ($dropdownATag.length === 0) { + throw "There is no navbarMenu with menuName equal to '" + message.menuName + "'"; + } + + var $dropdownTabset = $dropdownATag.find("+ ul.dropdown-menu"); + var dropdownId = $dropdownTabset.attr("data-tabsetid"); + return { + $tabset: $dropdownTabset, + id: dropdownId + }; + } else if (message.target !== null) { + // if our item is to be placed next to a tab that is inside + // a navbarMenu, our item will also be inside + var $uncleTabset = $targetLiTag.parent("ul"); + + if ($uncleTabset.hasClass("dropdown-menu")) { + var uncleId = $uncleTabset.attr("data-tabsetid"); + return { + $tabset: $uncleTabset, + id: uncleId + }; + } + } + + return null; + } + }); // If the given tabset has no active tabs, select the first one + + function ensureTabsetHasVisibleTab($tabset) { + if ($tabset.find("li.active").not(".dropdown").length === 0) { + // Note: destTabValue may be null. We still want to proceed + // through the below logic and setValue so that the input + // value for the tabset gets updated (i.e. input$tabsetId + // should be null if there are no tabs). + var destTabValue = getFirstTab($tabset); + var inputBinding = $tabset.data('shiny-input-binding'); + var evt = jQuery.Event('shiny:updateinput'); + evt.binding = inputBinding; + $tabset.trigger(evt); + inputBinding.setValue($tabset[0], destTabValue); + } + } // Given a tabset ul jquery object, return the value of the first tab + // (in document order) that's visible and able to be selected. + + + function getFirstTab($ul) { + return $ul.find("li:visible a[data-toggle='tab']").first().attr("data-value") || null; + } + + function tabApplyFunction(target, func) { + var liTags = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + $.each(target, function (key, el) { + if (key === "$liTag") { + // $liTag is always just one jQuery element + func(el); + } else if (key === "$divTags") { + // $divTags is always an array (even if length = 1) + $.each(el, function (i, div) { + func(div); + }); + } else if (liTags && key === "$liTags") { + // $liTags is always an array (even if length = 0) + $.each(el, function (i, div) { + func(div); + }); + } + }); + } + + addMessageHandler("shiny-remove-tab", function (message) { + var $tabset = getTabset(message.inputId); + var $tabContent = getTabContent($tabset); + var target = getTargetTabs($tabset, $tabContent, message.target); + tabApplyFunction(target, removeEl); + ensureTabsetHasVisibleTab($tabset); + + function removeEl($el) { + exports.unbindAll($el, true); + $el.remove(); + } + }); + addMessageHandler("shiny-change-tab-visibility", function (message) { + var $tabset = getTabset(message.inputId); + var $tabContent = getTabContent($tabset); + var target = getTargetTabs($tabset, $tabContent, message.target); + tabApplyFunction(target, changeVisibility, true); + ensureTabsetHasVisibleTab($tabset); + + function changeVisibility($el) { + if (message.type === "show") $el.css("display", "");else if (message.type === "hide") { + $el.hide(); + $el.removeClass("active"); + } + } + }); + addMessageHandler('updateQueryString', function (message) { + // leave the bookmarking code intact + if (message.mode === "replace") { + window.history.replaceState(null, null, message.queryString); + return; + } + + var what = null; + if (message.queryString.charAt(0) === "#") what = "hash";else if (message.queryString.charAt(0) === "?") what = "query";else throw "The 'query' string must start with either '?' " + "(to update the query string) or with '#' (to " + "update the hash)."; + var path = window.location.pathname; + var oldQS = window.location.search; + var oldHash = window.location.hash; + /* Barbara -- December 2016 + Note: we could check if the new QS and/or hash are different + from the old one(s) and, if not, we could choose not to push + a new state (whether or not we would replace it is moot/ + inconsequential). However, I think that it is better to + interpret each call to `updateQueryString` as representing + new state (even if the message.queryString is the same), so + that check isn't even performed as of right now. + */ + + var relURL = path; + if (what === "query") relURL += message.queryString;else relURL += oldQS + message.queryString; // leave old QS if it exists + + window.history.pushState(null, null, relURL); // for the case when message.queryString has both a query string + // and a hash (`what = "hash"` allows us to trigger the + // hashchange event) + + if (message.queryString.indexOf("#") !== -1) what = "hash"; // for the case when there was a hash before, but there isn't + // any hash now (e.g. for when only the query string is updated) + + if (window.location.hash !== oldHash) what = "hash"; // This event needs to be triggered manually because pushState() never + // causes a hashchange event to be fired, + + if (what === "hash") $(document).trigger("hashchange"); + }); + addMessageHandler("resetBrush", function (message) { + exports.resetBrush(message.brushId); + }); // Progress reporting ==================================================== + + var progressHandlers = { + // Progress for a particular object + binding: function binding(message) { + var key = message.id; + var binding = this.$bindings[key]; + + if (binding) { + $(binding.el).trigger({ + type: 'shiny:outputinvalidated', + binding: binding, + name: key + }); + if (binding.showProgress) binding.showProgress(true); + } + }, + // Open a page-level progress bar + open: function open(message) { + if (message.style === "notification") { + // For new-style (starting in Shiny 0.14) progress indicators that use + // the notification API. + // Progress bar starts hidden; will be made visible if a value is provided + // during updates. + exports.notifications.show({ + html: "
") + '' + '
' + 'message ' + '' + '
' + '
', + id: message.id, + duration: null + }); + } else if (message.style === "old") { + // For old-style (Shiny <=0.13.2) progress indicators. + // Add progress container (for all progress items) if not already present + var $container = $('.shiny-progress-container'); + + if ($container.length === 0) { + $container = $('
'); + $(document.body).append($container); + } // Add div for just this progress ID + + + var depth = $('.shiny-progress.open').length; // The 'bar' class is needed for backward compatibility with Bootstrap 2. + + var $progress = $('
' + '
' + '
' + 'message' + '' + '
' + '
'); + $progress.attr('id', message.id); + $container.append($progress); // Stack bars + + var $progressBar = $progress.find('.progress'); + $progressBar.css('top', depth * $progressBar.height() + 'px'); // Stack text objects + + var $progressText = $progress.find('.progress-text'); + $progressText.css('top', 3 * $progressBar.height() + depth * $progressText.outerHeight() + 'px'); + $progress.hide(); + } + }, + // Update page-level progress bar + update: function update(message) { + if (message.style === "notification") { + // For new-style (starting in Shiny 0.14) progress indicators that use + // the notification API. + var $progress = $('#shiny-progress-' + message.id); + if ($progress.length === 0) return; + + if (typeof message.message !== 'undefined') { + $progress.find('.progress-message').text(message.message); + } + + if (typeof message.detail !== 'undefined') { + $progress.find('.progress-detail').text(message.detail); + } + + if (typeof message.value !== 'undefined' && message.value !== null) { + $progress.find('.progress').show(); + $progress.find('.progress-bar').width(message.value * 100 + '%'); + } + } else if (message.style === "old") { + // For old-style (Shiny <=0.13.2) progress indicators. + var $progress = $('#' + message.id + '.shiny-progress'); + + if (typeof message.message !== 'undefined') { + $progress.find('.progress-message').text(message.message); + } + + if (typeof message.detail !== 'undefined') { + $progress.find('.progress-detail').text(message.detail); + } + + if (typeof message.value !== 'undefined' && message.value !== null) { + $progress.find('.progress').show(); + $progress.find('.bar').width(message.value * 100 + '%'); + } + + $progress.fadeIn(); + } + }, + // Close page-level progress bar + close: function close(message) { + if (message.style === "notification") { + exports.notifications.remove(message.id); + } else if (message.style === "old") { + var $progress = $('#' + message.id + '.shiny-progress'); + $progress.removeClass('open'); + $progress.fadeOut({ + complete: function complete() { + $progress.remove(); // If this was the last shiny-progress, remove container + + if ($('.shiny-progress').length === 0) $('.shiny-progress-container').remove(); + } + }); + } + } + }; + exports.progressHandlers = progressHandlers; // Returns a URL which can be queried to get values from inside the server + // function. This is enabled with `options(shiny.testmode=TRUE)`. + + this.getTestSnapshotBaseUrl = function () { + var _ref2 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + _ref2$fullUrl = _ref2.fullUrl, + fullUrl = _ref2$fullUrl === void 0 ? true : _ref2$fullUrl; + + var loc = window.location; + var url = ""; + + if (fullUrl) { + // Strip off everything after last slash in path, like dirname() in R + url = loc.origin + loc.pathname.replace(/\/[^/]*$/, ""); + } + + url += "/session/" + encodeURIComponent(this.config.sessionId) + "/dataobj/shinytest?w=" + encodeURIComponent(this.config.workerId) + "&nonce=" + randomId(); + return url; + }; + }).call(ShinyApp.prototype); + + exports.showReconnectDialog = function () { + var reconnectTime = null; + + function updateTime() { + var $time = $("#shiny-reconnect-time"); // If the time has been removed, exit and don't reschedule this function. + + if ($time.length === 0) return; + var seconds = Math.floor((reconnectTime - new Date().getTime()) / 1000); + + if (seconds > 0) { + $time.text(" in " + seconds + "s"); + } else { + $time.text("..."); + } // Reschedule this function after 1 second + + + setTimeout(updateTime, 1000); + } + + return function (delay) { + reconnectTime = new Date().getTime() + delay; // If there's already a reconnect dialog, don't add another + + if ($('#shiny-reconnect-text').length > 0) return; + var html = 'Attempting to reconnect' + ''; + var action = 'Try now'; + exports.notifications.show({ + id: "reconnect", + html: html, + action: action, + duration: null, + closeButton: false, + type: 'warning' + }); + updateTime(); + }; + }(); + + exports.hideReconnectDialog = function () { + exports.notifications.remove("reconnect"); + }; //--------------------------------------------------------------------- + // Source file: ../srcjs/notifications.js + + + exports.notifications = function () { + // Milliseconds to fade in or out + var fadeDuration = 250; + + function show() { + var _ref3 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + _ref3$html = _ref3.html, + html = _ref3$html === void 0 ? '' : _ref3$html, + _ref3$action = _ref3.action, + action = _ref3$action === void 0 ? '' : _ref3$action, + _ref3$deps = _ref3.deps, + deps = _ref3$deps === void 0 ? [] : _ref3$deps, + _ref3$duration = _ref3.duration, + duration = _ref3$duration === void 0 ? 5000 : _ref3$duration, + _ref3$id = _ref3.id, + id = _ref3$id === void 0 ? null : _ref3$id, + _ref3$closeButton = _ref3.closeButton, + closeButton = _ref3$closeButton === void 0 ? true : _ref3$closeButton, + _ref3$type = _ref3.type, + type = _ref3$type === void 0 ? null : _ref3$type; + + if (!id) id = randomId(); // Create panel if necessary + + _createPanel(); // Get existing DOM element for this ID, or create if needed. + + + var $notification = _get(id); + + if ($notification.length === 0) $notification = _create(id); // Render html and dependencies + + var newHtml = "
".concat(html, "
") + "
".concat(action, "
"); + var $content = $notification.find('.shiny-notification-content'); + exports.renderContent($content, { + html: newHtml, + deps: deps + }); // Remove any existing classes of the form 'shiny-notification-xxxx'. + // The xxxx would be strings like 'warning'. + + var classes = $notification.attr('class').split(/\s+/).filter(function (cls) { + return cls.match(/^shiny-notification-/); + }).join(' '); + $notification.removeClass(classes); // Add class. 'default' means no additional CSS class. + + if (type && type !== 'default') $notification.addClass('shiny-notification-' + type); // Make sure that the presence/absence of close button matches with value + // of `closeButton`. + + var $close = $notification.find('.shiny-notification-close'); + + if (closeButton && $close.length === 0) { + $notification.append('
×
'); + } else if (!closeButton && $close.length !== 0) { + $close.remove(); + } // If duration was provided, schedule removal. If not, clear existing + // removal callback (this happens if a message was first added with + // a duration, and then updated with no duration). + + + if (duration) _addRemovalCallback(id, duration);else _clearRemovalCallback(id); + return id; + } + + function remove(id) { + _get(id).fadeOut(fadeDuration, function () { + exports.unbindAll(this); + $(this).remove(); // If no more notifications, remove the panel from the DOM. + + if (_ids().length === 0) { + _getPanel().remove(); + } + }); + } // Returns an individual notification DOM object (wrapped in jQuery). + + + function _get(id) { + if (!id) return null; + return _getPanel().find('#shiny-notification-' + $escape(id)); + } // Return array of all notification IDs + + + function _ids() { + return _getPanel().find('.shiny-notification').map(function () { + return this.id.replace(/shiny-notification-/, ''); + }).get(); + } // Returns the notification panel DOM object (wrapped in jQuery). + + + function _getPanel() { + return $('#shiny-notification-panel'); + } // Create notifications panel and return the jQuery object. If the DOM + // element already exists, just return it. + + + function _createPanel() { + var $panel = _getPanel(); + + if ($panel.length > 0) return $panel; + $(document.body).append('
'); + return $panel; + } // Create a notification DOM element and return the jQuery object. If the + // DOM element already exists for the ID, just return it without creating. + + + function _create(id) { + var $notification = _get(id); + + if ($notification.length === 0) { + $notification = $("
") + '
×
' + '
' + '
'); + $notification.find('.shiny-notification-close').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + remove(id); + }); + + _getPanel().append($notification); + } + + return $notification; + } // Add a callback to remove a notification after a delay in ms. + + + function _addRemovalCallback(id, delay) { + // If there's an existing removalCallback, clear it before adding the new + // one. + _clearRemovalCallback(id); // Attach new removal callback + + + var removalCallback = setTimeout(function () { + remove(id); + }, delay); + + _get(id).data('removalCallback', removalCallback); + } // Clear a removal callback from a notification, if present. + + + function _clearRemovalCallback(id) { + var $notification = _get(id); + + var oldRemovalCallback = $notification.data('removalCallback'); + + if (oldRemovalCallback) { + clearTimeout(oldRemovalCallback); + } + } + + return { + show: show, + remove: remove + }; + }(); //--------------------------------------------------------------------- + // Source file: ../srcjs/modal.js + + + exports.modal = { + // Show a modal dialog. This is meant to handle two types of cases: one is + // that the content is a Bootstrap modal dialog, and the other is that the + // content is non-Bootstrap. Bootstrap modals require some special handling, + // which is coded in here. + show: function show() { + var _ref4 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, + _ref4$html = _ref4.html, + html = _ref4$html === void 0 ? '' : _ref4$html, + _ref4$deps = _ref4.deps, + deps = _ref4$deps === void 0 ? [] : _ref4$deps; + + // If there was an existing Bootstrap modal, then there will be a modal- + // backdrop div that was added outside of the modal wrapper, and it must be + // removed; otherwise there can be multiple of these divs. + $('.modal-backdrop').remove(); // Get existing wrapper DOM element, or create if needed. + + var $modal = $('#shiny-modal-wrapper'); + + if ($modal.length === 0) { + $modal = $('
'); + $(document.body).append($modal); // If the wrapper's content is a Bootstrap modal, then when the inner + // modal is hidden, remove the entire thing, including wrapper. + + $modal.on('hidden.bs.modal', function (e) { + if (e.target === $("#shiny-modal")[0]) { + exports.unbindAll($modal); + $modal.remove(); + } + }); + } + + $modal.on('keydown.shinymodal', function (e) { + // If we're listening for Esc, don't let the event propagate. See + // https://github.com/rstudio/shiny/issues/1453. The value of + // data("keyboard") needs to be checked inside the handler, because at + // the time that $modal.on() is called, the $("#shiny-modal") div doesn't + // yet exist. + if ($("#shiny-modal").data("keyboard") === false) return; + + if (e.keyCode === 27) { + e.stopPropagation(); + e.preventDefault(); + } + }); // Set/replace contents of wrapper with html. + + exports.renderContent($modal, { + html: html, + deps: deps + }); + }, + remove: function remove() { + var $modal = $('#shiny-modal-wrapper'); + $modal.off('keydown.shinymodal'); // Look for a Bootstrap modal and if present, trigger hide event. This will + // trigger the hidden.bs.modal callback that we set in show(), which unbinds + // and removes the element. + + if ($modal.find('.modal').length > 0) { + $modal.find('.modal').modal('hide'); + } else { + // If not a Bootstrap modal dialog, simply unbind and remove it. + exports.unbindAll($modal); + $modal.remove(); + } + } + }; //--------------------------------------------------------------------- + // Source file: ../srcjs/file_processor.js + // Generic driver class for doing chunk-wise asynchronous processing of a + // FileList object. Subclass/clone it and override the `on*` functions to + // make it do something useful. + + var FileProcessor = function FileProcessor(files) { + this.files = files; + this.fileIndex = -1; // Currently need to use small chunk size because R-Websockets can't + // handle continuation frames + + this.aborted = false; + this.completed = false; // TODO: Register error/abort callbacks + + this.$run(); + }; + + (function () { + // Begin callbacks. Subclassers/cloners may override any or all of these. + this.onBegin = function (files, cont) { + setTimeout(cont, 0); + }; + + this.onFile = function (file, cont) { + setTimeout(cont, 0); + }; + + this.onComplete = function () {}; + + this.onAbort = function () {}; // End callbacks + // Aborts processing, unless it's already completed + + + this.abort = function () { + if (this.completed || this.aborted) return; + this.aborted = true; + this.onAbort(); + }; // Returns a bound function that will call this.$run one time. + + + this.$getRun = function () { + var self = this; + var called = false; + return function () { + if (called) return; + called = true; + self.$run(); + }; + }; // This function will be called multiple times to advance the process. + // It relies on the state of the object's fields to know what to do next. + + + this.$run = function () { + if (this.aborted || this.completed) return; + + if (this.fileIndex < 0) { + // Haven't started yet--begin + this.fileIndex = 0; + this.onBegin(this.files, this.$getRun()); + return; + } + + if (this.fileIndex === this.files.length) { + // Just ended + this.completed = true; + this.onComplete(); + return; + } // If we got here, then we have a file to process, or we are + // in the middle of processing a file, or have just finished + // processing a file. + + + var file = this.files[this.fileIndex++]; + this.onFile(file, this.$getRun()); + }; + }).call(FileProcessor.prototype); //--------------------------------------------------------------------- + // Source file: ../srcjs/binding_registry.js + + var BindingRegistry = function BindingRegistry() { + this.bindings = []; + this.bindingNames = {}; + }; + + (function () { + this.register = function (binding, bindingName, priority) { + var bindingObj = { + binding: binding, + priority: priority || 0 + }; + this.bindings.unshift(bindingObj); + + if (bindingName) { + this.bindingNames[bindingName] = bindingObj; + binding.name = bindingName; + } + }; + + this.setPriority = function (bindingName, priority) { + var bindingObj = this.bindingNames[bindingName]; + if (!bindingObj) throw "Tried to set priority on unknown binding " + bindingName; + bindingObj.priority = priority || 0; + }; + + this.getPriority = function (bindingName) { + var bindingObj = this.bindingNames[bindingName]; + if (!bindingObj) return false; + return bindingObj.priority; + }; + + this.getBindings = function () { + // Sort the bindings. The ones with higher priority are consulted + // first; ties are broken by most-recently-registered. + return mergeSort(this.bindings, function (a, b) { + return b.priority - a.priority; + }); + }; + }).call(BindingRegistry.prototype); + var inputBindings = exports.inputBindings = new BindingRegistry(); + var outputBindings = exports.outputBindings = new BindingRegistry(); //--------------------------------------------------------------------- + // Source file: ../srcjs/output_binding.js + + var OutputBinding = exports.OutputBinding = function () {}; + + (function () { + // Returns a jQuery object or element array that contains the + // descendants of scope that match this binding + this.find = function (scope) { + throw "Not implemented"; + }; + + this.getId = function (el) { + return el['data-input-id'] || el.id; + }; + + this.onValueChange = function (el, data) { + this.clearError(el); + this.renderValue(el, data); + }; + + this.onValueError = function (el, err) { + this.renderError(el, err); + }; + + this.renderError = function (el, err) { + this.clearError(el); + + if (err.message === '') { + // not really error, but we just need to wait (e.g. action buttons) + $(el).empty(); + return; + } + + var errClass = 'shiny-output-error'; + + if (err.type !== null) { + // use the classes of the error condition as CSS class names + errClass = errClass + ' ' + $.map(asArray(err.type), function (type) { + return errClass + '-' + type; + }).join(' '); + } + + $(el).addClass(errClass).text(err.message); + }; + + this.clearError = function (el) { + $(el).attr('class', function (i, c) { + return c.replace(/(^|\s)shiny-output-error\S*/g, ''); + }); + }; + + this.showProgress = function (el, show) { + var RECALC_CLASS = 'recalculating'; + if (show) $(el).addClass(RECALC_CLASS);else $(el).removeClass(RECALC_CLASS); + }; + }).call(OutputBinding.prototype); //--------------------------------------------------------------------- + // Source file: ../srcjs/output_binding_text.js + + var textOutputBinding = new OutputBinding(); + $.extend(textOutputBinding, { + find: function find(scope) { + return $(scope).find('.shiny-text-output'); + }, + renderValue: function renderValue(el, data) { + $(el).text(data); + } + }); + outputBindings.register(textOutputBinding, 'shiny.textOutput'); //--------------------------------------------------------------------- + // Source file: ../srcjs/output_binding_image.js + + var imageOutputBinding = new OutputBinding(); + $.extend(imageOutputBinding, { + find: function find(scope) { + return $(scope).find('.shiny-image-output, .shiny-plot-output'); + }, + renderValue: function renderValue(el, data) { + // The overall strategy: + // * Clear out existing image and event handlers. + // * Create new image. + // * Create various event handlers. + // * Bind those event handlers to events. + // * Insert the new image. + var outputId = this.getId(el); + var $el = $(el); + var img; // Get existing img element if present. + + var $img = $el.find('img'); + + if ($img.length === 0) { + // If a img element is not already present, that means this is either + // the first time renderValue() has been called, or this is after an + // error. + img = document.createElement('img'); + $el.append(img); + $img = $(img); + } else { + // Trigger custom 'reset' event for any existing images in the div + img = $img[0]; + $img.trigger('reset'); + } + + if (!data) { + $el.empty(); + return; + } // If value is undefined, return alternate. Sort of like ||, except it won't + // return alternate for other falsy values (0, false, null). + + + function OR(value, alternate) { + if (value === undefined) return alternate; + return value; + } + + var opts = { + clickId: $el.data('click-id'), + clickClip: OR(strToBool($el.data('click-clip')), true), + dblclickId: $el.data('dblclick-id'), + dblclickClip: OR(strToBool($el.data('dblclick-clip')), true), + dblclickDelay: OR($el.data('dblclick-delay'), 400), + hoverId: $el.data('hover-id'), + hoverClip: OR(strToBool($el.data('hover-clip')), true), + hoverDelayType: OR($el.data('hover-delay-type'), 'debounce'), + hoverDelay: OR($el.data('hover-delay'), 300), + hoverNullOutside: OR(strToBool($el.data('hover-null-outside')), false), + brushId: $el.data('brush-id'), + brushClip: OR(strToBool($el.data('brush-clip')), true), + brushDelayType: OR($el.data('brush-delay-type'), 'debounce'), + brushDelay: OR($el.data('brush-delay'), 300), + brushFill: OR($el.data('brush-fill'), '#666'), + brushStroke: OR($el.data('brush-stroke'), '#000'), + brushOpacity: OR($el.data('brush-opacity'), 0.3), + brushDirection: OR($el.data('brush-direction'), 'xy'), + brushResetOnNew: OR(strToBool($el.data('brush-reset-on-new')), false), + coordmap: data.coordmap + }; + + if (opts.brushFill === "auto") { + opts.brushFill = getComputedLinkColor($el[0]); + } + + if (opts.brushStroke === "auto") { + opts.brushStroke = getStyle($el[0], "color"); + } // Copy items from data to img. Don't set the coordmap as an attribute. + + + $.each(data, function (key, value) { + if (value === null || key === 'coordmap') { + return; + } // this checks only against base64 encoded src values + // images put here are only from renderImage and renderPlot + + + if (key === "src" && value === img.getAttribute("src")) { + // Ensure the browser actually fires an onLoad event, which doesn't + // happen on WebKit if the value we set on src is the same as the + // value it already has + // https://github.com/rstudio/shiny/issues/2197 + // https://stackoverflow.com/questions/5024111/javascript-image-onload-doesnt-fire-in-webkit-if-loading-same-image + img.removeAttribute("src"); + } + + img.setAttribute(key, value); + }); // Unset any attributes in the current img that were not provided in the + // new data. + + for (var i = 0; i < img.attributes.length; i++) { + var attrib = img.attributes[i]; // Need to check attrib.specified on IE because img.attributes contains + // all possible attributes on IE. + + if (attrib.specified && !data.hasOwnProperty(attrib.name)) { + img.removeAttribute(attrib.name); + } + } + + if (!opts.coordmap) { + opts.coordmap = { + panels: [], + dims: { + // These values be set to the naturalWidth and naturalHeight once the image has loaded + height: null, + width: null + } + }; + } // Remove event handlers that were added in previous runs of this function. + + + $el.off('.image_output'); + $img.off('.image_output'); // When the image loads, initialize all the interaction handlers. When the + // value of src is set, the browser may not load the image immediately, + // even if it's a data URL. If we try to initialize this stuff + // immediately, it can cause problems because we use we need the raw image + // height and width + + $img.off("load.shiny_image_interaction"); + $img.one("load.shiny_image_interaction", function () { + imageutils.initCoordmap($el, opts.coordmap); // This object listens for mousedowns, and triggers mousedown2 and dblclick2 + // events as appropriate. + + var clickInfo = imageutils.createClickInfo($el, opts.dblclickId, opts.dblclickDelay); + $el.on('mousedown.image_output', clickInfo.mousedown); + + if (browser.isIE && browser.IEVersion === 8) { + $el.on('dblclick.image_output', clickInfo.dblclickIE8); + } // ---------------------------------------------------------- + // Register the various event handlers + // ---------------------------------------------------------- + + + if (opts.clickId) { + imageutils.disableDrag($el, $img); + var clickHandler = imageutils.createClickHandler(opts.clickId, opts.clickClip, opts.coordmap); + $el.on('mousedown2.image_output', clickHandler.mousedown); + $el.on('resize.image_output', clickHandler.onResize); // When img is reset, do housekeeping: clear $el's mouse listener and + // call the handler's onResetImg callback. + + $img.on('reset.image_output', clickHandler.onResetImg); + } + + if (opts.dblclickId) { + imageutils.disableDrag($el, $img); // We'll use the clickHandler's mousedown function, but register it to + // our custom 'dblclick2' event. + + var dblclickHandler = imageutils.createClickHandler(opts.dblclickId, opts.clickClip, opts.coordmap); + $el.on('dblclick2.image_output', dblclickHandler.mousedown); + $el.on('resize.image_output', dblclickHandler.onResize); + $img.on('reset.image_output', dblclickHandler.onResetImg); + } + + if (opts.hoverId) { + imageutils.disableDrag($el, $img); + var hoverHandler = imageutils.createHoverHandler(opts.hoverId, opts.hoverDelay, opts.hoverDelayType, opts.hoverClip, opts.hoverNullOutside, opts.coordmap); + $el.on('mousemove.image_output', hoverHandler.mousemove); + $el.on('mouseout.image_output', hoverHandler.mouseout); + $el.on('resize.image_output', hoverHandler.onResize); + $img.on('reset.image_output', hoverHandler.onResetImg); + } + + if (opts.brushId) { + imageutils.disableDrag($el, $img); + var brushHandler = imageutils.createBrushHandler(opts.brushId, $el, opts, opts.coordmap, outputId); + $el.on('mousedown.image_output', brushHandler.mousedown); + $el.on('mousemove.image_output', brushHandler.mousemove); + $el.on('resize.image_output', brushHandler.onResize); + $img.on('reset.image_output', brushHandler.onResetImg); + } + + if (opts.clickId || opts.dblclickId || opts.hoverId || opts.brushId) { + $el.addClass('crosshair'); + } + + if (data.error) console.log('Error on server extracting coordmap: ' + data.error); + }); + }, + renderError: function renderError(el, err) { + $(el).find('img').trigger('reset'); + OutputBinding.prototype.renderError.call(this, el, err); + }, + clearError: function clearError(el) { + // Remove all elements except img and the brush; this is usually just + // error messages. + $(el).contents().filter(function () { + return this.tagName !== "IMG" && this.id !== el.id + '_brush'; + }).remove(); + OutputBinding.prototype.clearError.call(this, el); + }, + resize: function resize(el, width, height) { + $(el).find("img").trigger("resize"); + } + }); + outputBindings.register(imageOutputBinding, 'shiny.imageOutput'); + var imageutils = {}; + + imageutils.disableDrag = function ($el, $img) { + // Make image non-draggable (Chrome, Safari) + $img.css('-webkit-user-drag', 'none'); // Firefox, IE<=10 + // First remove existing handler so we don't keep adding handlers. + + $img.off('dragstart.image_output'); + $img.on('dragstart.image_output', function () { + return false; + }); // Disable selection of image and text when dragging in IE<=10 + + $el.off('selectstart.image_output'); + $el.on('selectstart.image_output', function () { + return false; + }); + }; // Modifies the panel objects in a coordmap, adding scaleImgToData(), + // scaleDataToImg(), and clipImg() functions to each one. The panel objects + // use img and data coordinates only; they do not use css coordinates. The + // domain is in data coordinates; the range is in img coordinates. + + + imageutils.initPanelScales = function (panels) { + // Map a value x from a domain to a range. If clip is true, clip it to the + // range. + function mapLinear(x, domainMin, domainMax, rangeMin, rangeMax, clip) { + // By default, clip to range + clip = clip || true; + var factor = (rangeMax - rangeMin) / (domainMax - domainMin); + var val = x - domainMin; + var newval = val * factor + rangeMin; + + if (clip) { + var max = Math.max(rangeMax, rangeMin); + var min = Math.min(rangeMax, rangeMin); + if (newval > max) newval = max;else if (newval < min) newval = min; + } + + return newval; + } // Create scale and inverse-scale functions for a single direction (x or y). + + + function scaler1D(domainMin, domainMax, rangeMin, rangeMax, logbase) { + return { + scale: function scale(val, clip) { + if (logbase) val = Math.log(val) / Math.log(logbase); + return mapLinear(val, domainMin, domainMax, rangeMin, rangeMax, clip); + }, + scaleInv: function scaleInv(val, clip) { + var res = mapLinear(val, rangeMin, rangeMax, domainMin, domainMax, clip); + if (logbase) res = Math.pow(logbase, res); + return res; + } + }; + } // Modify panel, adding scale and inverse-scale functions that take objects + // like {x:1, y:3}, and also add clip function. + + + function addScaleFuns(panel) { + var d = panel.domain; + var r = panel.range; + var xlog = panel.log && panel.log.x ? panel.log.x : null; + var ylog = panel.log && panel.log.y ? panel.log.y : null; + var xscaler = scaler1D(d.left, d.right, r.left, r.right, xlog); + var yscaler = scaler1D(d.bottom, d.top, r.bottom, r.top, ylog); // Given an object of form {x:1, y:2}, or {x:1, xmin:2:, ymax: 3}, convert + // from data coordinates to img. Whether a value is converted as x or y + // depends on the first character of the key. + + panel.scaleDataToImg = function (val, clip) { + return mapValues(val, function (value, key) { + var prefix = key.substring(0, 1); + + if (prefix === "x") { + return xscaler.scale(value, clip); + } else if (prefix === "y") { + return yscaler.scale(value, clip); + } + + return null; + }); + }; + + panel.scaleImgToData = function (val, clip) { + return mapValues(val, function (value, key) { + var prefix = key.substring(0, 1); + + if (prefix === "x") { + return xscaler.scaleInv(value, clip); + } else if (prefix === "y") { + return yscaler.scaleInv(value, clip); + } + + return null; + }); + }; // Given a scaled offset (in img pixels), clip it to the nearest panel region. + + + panel.clipImg = function (offset_img) { + var newOffset = { + x: offset_img.x, + y: offset_img.y + }; + var bounds = panel.range; + if (offset_img.x > bounds.right) newOffset.x = bounds.right;else if (offset_img.x < bounds.left) newOffset.x = bounds.left; + if (offset_img.y > bounds.bottom) newOffset.y = bounds.bottom;else if (offset_img.y < bounds.top) newOffset.y = bounds.top; + return newOffset; + }; + } // Add the functions to each panel object. + + + for (var i = 0; i < panels.length; i++) { + var panel = panels[i]; + addScaleFuns(panel); + } + }; // This adds functions to the coordmap object to handle various + // coordinate-mapping tasks, and send information to the server. The input + // coordmap is an array of objects, each of which represents a panel. coordmap + // must be an array, even if empty, so that it can be modified in place; when + // empty, we add a dummy panel to the array. It also calls initPanelScales, + // which modifies each panel object to have scaleImgToData, scaleDataToImg, + // and clip functions. + // + // There are three coordinate spaces which we need to translate between: + // + // 1. css: The pixel coordinates in the web browser, also known as CSS pixels. + // The origin is the upper-left corner of the (not including padding + // and border). + // 2. img: The pixel coordinates of the image data. A common case is on a + // HiDPI device, where the source PNG image could be 1000 pixels wide but + // be displayed in 500 CSS pixels. Another case is when the image has + // additional scaling due to CSS transforms or width. + // 3. data: The coordinates in the data space. This is a bit more complicated + // than the other two, because there can be multiple panels (as in facets). + + + imageutils.initCoordmap = function ($el, coordmap) { + var $img = $el.find("img"); + var img = $img[0]; // If we didn't get any panels, create a dummy one where the domain and range + // are simply the pixel dimensions. + // that we modify. + + if (coordmap.panels.length === 0) { + var bounds = { + top: 0, + left: 0, + right: img.clientWidth - 1, + bottom: img.clientHeight - 1 + }; + coordmap.panels[0] = { + domain: bounds, + range: bounds, + mapping: {} + }; + } // If no dim height and width values are found, set them to the raw image height and width + // These values should be the same... + // This is only done to initialize an image output, whose height and width are unknown until the image is retrieved + + + coordmap.dims.height = coordmap.dims.height || img.naturalHeight; + coordmap.dims.width = coordmap.dims.width || img.naturalWidth; // Add scaling functions to each panel + + imageutils.initPanelScales(coordmap.panels); // This returns the offset of the mouse in CSS pixels relative to the img, + // but not including the padding or border, if present. + + coordmap.mouseOffsetCss = function (mouseEvent) { + var img_origin = findOrigin($img); // The offset of the mouse from the upper-left corner of the img, in + // pixels. + + return { + x: mouseEvent.pageX - img_origin.x, + y: mouseEvent.pageY - img_origin.y + }; + }; // Given an offset in an img in CSS pixels, return the corresponding offset + // in source image pixels. The offset_css can have properties like "x", + // "xmin", "y", and "ymax" -- anything that starts with "x" and "y". If the + // img content is 1000 pixels wide, but is scaled to 400 pixels on screen, + // and the input is x:400, then this will return x:1000. + + + coordmap.scaleCssToImg = function (offset_css) { + var pixel_scaling = coordmap.imgToCssScalingRatio(); + var result = mapValues(offset_css, function (value, key) { + var prefix = key.substring(0, 1); + + if (prefix === "x") { + return offset_css[key] / pixel_scaling.x; + } else if (prefix === "y") { + return offset_css[key] / pixel_scaling.y; + } + + return null; + }); + return result; + }; // Given an offset in an img, in source image pixels, return the + // corresponding offset in CSS pixels. If the img content is 1000 pixels + // wide, but is scaled to 400 pixels on screen, and the input is x:1000, + // then this will return x:400. + + + coordmap.scaleImgToCss = function (offset_img) { + var pixel_scaling = coordmap.imgToCssScalingRatio(); + var result = mapValues(offset_img, function (value, key) { + var prefix = key.substring(0, 1); + + if (prefix === "x") { + return offset_img[key] * pixel_scaling.x; + } else if (prefix === "y") { + return offset_img[key] * pixel_scaling.y; + } + + return null; + }); + return result; + }; // Returns the x and y ratio the image content is scaled to on screen. If + // the image data is 1000 pixels wide and is scaled to 300 pixels on screen, + // then this returns 0.3. (Note the 300 pixels refers to CSS pixels.) + + + coordmap.imgToCssScalingRatio = function () { + var img_dims = findDims($img); + return { + x: img_dims.x / coordmap.dims.width, + y: img_dims.y / coordmap.dims.height + }; + }; + + coordmap.cssToImgScalingRatio = function () { + var res = coordmap.imgToCssScalingRatio(); + return { + x: 1 / res.x, + y: 1 / res.y + }; + }; // Given an offset in css pixels, return an object representing which panel + // it's in. The `expand` argument tells it to expand the panel area by that + // many pixels. It's possible for an offset to be within more than one + // panel, because of the `expand` value. If that's the case, find the + // nearest panel. + + + coordmap.getPanelCss = function (offset_css) { + var expand = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var offset_img = coordmap.scaleCssToImg(offset_css); + var x = offset_img.x; + var y = offset_img.y; // Convert expand from css pixels to img pixels + + var cssToImgRatio = coordmap.cssToImgScalingRatio(); + var expand_img = { + x: expand * cssToImgRatio.x, + y: expand * cssToImgRatio.y + }; + var matches = []; // Panels that match + + var dists = []; // Distance of offset to each matching panel + + var b; + + for (var i = 0; i < coordmap.panels.length; i++) { + b = coordmap.panels[i].range; + + if (x <= b.right + expand_img.x && x >= b.left - expand_img.x && y <= b.bottom + expand_img.y && y >= b.top - expand_img.y) { + matches.push(coordmap.panels[i]); // Find distance from edges for x and y + + var xdist = 0; + var ydist = 0; + + if (x > b.right && x <= b.right + expand_img.x) { + xdist = x - b.right; + } else if (x < b.left && x >= b.left - expand_img.x) { + xdist = x - b.left; + } + + if (y > b.bottom && y <= b.bottom + expand_img.y) { + ydist = y - b.bottom; + } else if (y < b.top && y >= b.top - expand_img.y) { + ydist = y - b.top; + } // Cartesian distance + + + dists.push(Math.sqrt(Math.pow(xdist, 2) + Math.pow(ydist, 2))); + } + } + + if (matches.length) { + // Find shortest distance + var min_dist = Math.min.apply(null, dists); + + for (i = 0; i < matches.length; i++) { + if (dists[i] === min_dist) { + return matches[i]; + } + } + } + + return null; + }; // Is an offset (in css pixels) in a panel? If supplied, `expand` tells us + // to expand the panels by that many pixels in all directions. + + + coordmap.isInPanelCss = function (offset_css) { + var expand = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + if (coordmap.getPanelCss(offset_css, expand)) return true; + return false; + }; // Returns a function that sends mouse coordinates, scaled to data space. + // If that function is passed a null event, it will send null. + + + coordmap.mouseCoordinateSender = function (inputId, clip, nullOutside) { + if (clip === undefined) clip = true; + if (nullOutside === undefined) nullOutside = false; + return function (e) { + if (e === null) { + exports.setInputValue(inputId, null); + return; + } + + var coords = {}; + var coords_css = coordmap.mouseOffsetCss(e); // If outside of plotting region + + if (!coordmap.isInPanelCss(coords_css)) { + if (nullOutside) { + exports.setInputValue(inputId, null); + return; + } + + if (clip) return; + coords.coords_css = coords_css; + coords.coords_img = coordmap.scaleCssToImg(coords_css); + exports.setInputValue(inputId, coords, { + priority: "event" + }); + return; + } + + var panel = coordmap.getPanelCss(coords_css); + var coords_img = coordmap.scaleCssToImg(coords_css); + var coords_data = panel.scaleImgToData(coords_img); + coords.x = coords_data.x; + coords.y = coords_data.y; + coords.coords_css = coords_css; + coords.coords_img = coords_img; + coords.img_css_ratio = coordmap.cssToImgScalingRatio(); // Add the panel (facet) variables, if present + + $.extend(coords, panel.panel_vars); // Add variable name mappings + + coords.mapping = panel.mapping; // Add scaling information + + coords.domain = panel.domain; + coords.range = panel.range; + coords.log = panel.log; + exports.setInputValue(inputId, coords, { + priority: "event" + }); + }; + }; + }; // Given two sets of x/y coordinates, return an object representing the min + // and max x and y values. (This could be generalized to any number of + // points). + + + imageutils.findBox = function (offset1, offset2) { + return { + xmin: Math.min(offset1.x, offset2.x), + xmax: Math.max(offset1.x, offset2.x), + ymin: Math.min(offset1.y, offset2.y), + ymax: Math.max(offset1.y, offset2.y) + }; + }; // Shift an array of values so that they are within a min and max. The vals + // will be shifted so that they maintain the same spacing internally. If the + // range in vals is larger than the range of min and max, the result might not + // make sense. + + + imageutils.shiftToRange = function (vals, min, max) { + if (!(vals instanceof Array)) vals = [vals]; + var maxval = Math.max.apply(null, vals); + var minval = Math.min.apply(null, vals); + var shiftAmount = 0; + + if (maxval > max) { + shiftAmount = max - maxval; + } else if (minval < min) { + shiftAmount = min - minval; + } + + var newvals = []; + + for (var i = 0; i < vals.length; i++) { + newvals[i] = vals[i] + shiftAmount; + } + + return newvals; + }; // This object provides two public event listeners: mousedown, and + // dblclickIE8. + // We need to make sure that, when the image is listening for double- + // clicks, that a double-click doesn't trigger two click events. We'll + // trigger custom mousedown2 and dblclick2 events with this mousedown + // listener. + + + imageutils.createClickInfo = function ($el, dblclickId, dblclickDelay) { + var clickTimer = null; + var pending_e = null; // A pending mousedown2 event + // Create a new event of type eventType (like 'mousedown2'), and trigger + // it with the information stored in this.e. + + function triggerEvent(newEventType, e) { + // Extract important info from e and construct a new event with type + // eventType. + var e2 = $.Event(newEventType, { + which: e.which, + pageX: e.pageX, + pageY: e.pageY + }); + $el.trigger(e2); + } + + function triggerPendingMousedown2() { + // It's possible that between the scheduling of a mousedown2 and the + // time this callback is executed, someone else triggers a + // mousedown2, so check for that. + if (pending_e) { + triggerEvent('mousedown2', pending_e); + pending_e = null; + } + } // Set a timer to trigger a mousedown2 event, using information from the + // last recorded mousdown event. + + + function scheduleMousedown2(e) { + pending_e = e; + clickTimer = setTimeout(function () { + triggerPendingMousedown2(); + }, dblclickDelay); + } + + function mousedown(e) { + // Listen for left mouse button only + if (e.which !== 1) return; // If no dblclick listener, immediately trigger a mousedown2 event. + + if (!dblclickId) { + triggerEvent('mousedown2', e); + return; + } // If there's a dblclick listener, make sure not to count this as a + // click on the first mousedown; we need to wait for the dblclick + // delay before we can be sure this click was a single-click. + + + if (pending_e === null) { + scheduleMousedown2(e); + } else { + clearTimeout(clickTimer); // If second click is too far away, it doesn't count as a double + // click. Instead, immediately trigger a mousedown2 for the previous + // click, and set this click as a new first click. + + if (pending_e && Math.abs(pending_e.pageX - e.pageX) > 2 || Math.abs(pending_e.pageY - e.pageY) > 2) { + triggerPendingMousedown2(); + scheduleMousedown2(e); + } else { + // The second click was close to the first one. If it happened + // within specified delay, trigger our custom 'dblclick2' event. + pending_e = null; + triggerEvent('dblclick2', e); + } + } + } // IE8 needs a special hack because when you do a double-click it doesn't + // trigger the click event twice - it directly triggers dblclick. + + + function dblclickIE8(e) { + e.which = 1; // In IE8, e.which is 0 instead of 1. ??? + + triggerEvent('dblclick2', e); + } + + return { + mousedown: mousedown, + dblclickIE8: dblclickIE8 + }; + }; // ---------------------------------------------------------- + // Handler creators for click, hover, brush. + // Each of these returns an object with a few public members. These public + // members are callbacks that are meant to be bound to events on $el with + // the same name (like 'mousedown'). + // ---------------------------------------------------------- + + + imageutils.createClickHandler = function (inputId, clip, coordmap) { + var clickInfoSender = coordmap.mouseCoordinateSender(inputId, clip); + return { + mousedown: function mousedown(e) { + // Listen for left mouse button only + if (e.which !== 1) return; + clickInfoSender(e); + }, + onResetImg: function onResetImg() { + clickInfoSender(null); + }, + onResize: null + }; + }; + + imageutils.createHoverHandler = function (inputId, delay, delayType, clip, nullOutside, coordmap) { + var sendHoverInfo = coordmap.mouseCoordinateSender(inputId, clip, nullOutside); + var hoverInfoSender; + if (delayType === 'throttle') hoverInfoSender = new Throttler(null, sendHoverInfo, delay);else hoverInfoSender = new Debouncer(null, sendHoverInfo, delay); // What to do when mouse exits the image + + var mouseout; + if (nullOutside) mouseout = function mouseout() { + hoverInfoSender.normalCall(null); + };else mouseout = function mouseout() {}; + return { + mousemove: function mousemove(e) { + hoverInfoSender.normalCall(e); + }, + mouseout: mouseout, + onResetImg: function onResetImg() { + hoverInfoSender.immediateCall(null); + }, + onResize: null + }; + }; // Returns a brush handler object. This has three public functions: + // mousedown, mousemove, and onResetImg. + + + imageutils.createBrushHandler = function (inputId, $el, opts, coordmap, outputId) { + // Parameter: expand the area in which a brush can be started, by this + // many pixels in all directions. (This should probably be a brush option) + var expandPixels = 20; // Represents the state of the brush + + var brush = imageutils.createBrush($el, opts, coordmap, expandPixels); // Brush IDs can span multiple image/plot outputs. When an output is brushed, + // if a brush with the same ID is active on a different image/plot, it must + // be dismissed (but without sending any data to the server). We implement + // this by sending the shiny-internal:brushed event to all plots, and letting + // each plot decide for itself what to do. + // + // The decision to have the event sent to each plot (as opposed to a single + // event triggered on, say, the document) was made to make cleanup easier; + // listening on an event on the document would prevent garbage collection + // of plot outputs that are removed from the document. + + $el.on("shiny-internal:brushed.image_output", function (e, coords) { + // If the new brush shares our ID but not our output element ID, we + // need to clear our brush (if any). + if (coords.brushId === inputId && coords.outputId !== outputId) { + $el.data("mostRecentBrush", false); + brush.reset(); + } + }); // Set cursor to one of 7 styles. We need to set the cursor on the whole + // el instead of the brush div, because the brush div has + // 'pointer-events:none' so that it won't intercept pointer events. + // If `style` is null, don't add a cursor style. + + function setCursorStyle(style) { + $el.removeClass('crosshair grabbable grabbing ns-resize ew-resize nesw-resize nwse-resize'); + if (style) $el.addClass(style); + } + + function sendBrushInfo() { + var coords = brush.boundsData(); // We're in a new or reset state + + if (isNaN(coords.xmin)) { + exports.setInputValue(inputId, null); // Must tell other brushes to clear. + + imageOutputBinding.find(document).trigger("shiny-internal:brushed", { + brushId: inputId, + outputId: null + }); + return; + } + + var panel = brush.getPanel(); // Add the panel (facet) variables, if present + + $.extend(coords, panel.panel_vars); + coords.coords_css = brush.boundsCss(); + coords.coords_img = coordmap.scaleCssToImg(coords.coords_css); + coords.img_css_ratio = coordmap.cssToImgScalingRatio(); // Add variable name mappings + + coords.mapping = panel.mapping; // Add scaling information + + coords.domain = panel.domain; + coords.range = panel.range; + coords.log = panel.log; + coords.direction = opts.brushDirection; + coords.brushId = inputId; + coords.outputId = outputId; // Send data to server + + exports.setInputValue(inputId, coords); + $el.data("mostRecentBrush", true); + imageOutputBinding.find(document).trigger("shiny-internal:brushed", coords); + } + + var brushInfoSender; + + if (opts.brushDelayType === 'throttle') { + brushInfoSender = new Throttler(null, sendBrushInfo, opts.brushDelay); + } else { + brushInfoSender = new Debouncer(null, sendBrushInfo, opts.brushDelay); + } + + function mousedown(e) { + // This can happen when mousedown inside the graphic, then mouseup + // outside, then mousedown inside. Just ignore the second + // mousedown. + if (brush.isBrushing() || brush.isDragging() || brush.isResizing()) return; // Listen for left mouse button only + + if (e.which !== 1) return; // In general, brush uses css pixels, and coordmap uses img pixels. + + var offset_css = coordmap.mouseOffsetCss(e); // Ignore mousedown events outside of plotting region, expanded by + // a number of pixels specified in expandPixels. + + if (opts.brushClip && !coordmap.isInPanelCss(offset_css, expandPixels)) return; + brush.up({ + x: NaN, + y: NaN + }); + brush.down(offset_css); + + if (brush.isInResizeArea(offset_css)) { + brush.startResizing(offset_css); // Attach the move and up handlers to the window so that they respond + // even when the mouse is moved outside of the image. + + $(document).on('mousemove.image_brush', mousemoveResizing).on('mouseup.image_brush', mouseupResizing); + } else if (brush.isInsideBrush(offset_css)) { + brush.startDragging(offset_css); + setCursorStyle('grabbing'); // Attach the move and up handlers to the window so that they respond + // even when the mouse is moved outside of the image. + + $(document).on('mousemove.image_brush', mousemoveDragging).on('mouseup.image_brush', mouseupDragging); + } else { + var panel = coordmap.getPanelCss(offset_css, expandPixels); + brush.startBrushing(panel.clipImg(coordmap.scaleCssToImg(offset_css))); // Attach the move and up handlers to the window so that they respond + // even when the mouse is moved outside of the image. + + $(document).on('mousemove.image_brush', mousemoveBrushing).on('mouseup.image_brush', mouseupBrushing); + } + } // This sets the cursor style when it's in the el + + + function mousemove(e) { + // In general, brush uses css pixels, and coordmap uses img pixels. + var offset_css = coordmap.mouseOffsetCss(e); + + if (!(brush.isBrushing() || brush.isDragging() || brush.isResizing())) { + // Set the cursor depending on where it is + if (brush.isInResizeArea(offset_css)) { + var r = brush.whichResizeSides(offset_css); + + if (r.left && r.top || r.right && r.bottom) { + setCursorStyle('nwse-resize'); + } else if (r.left && r.bottom || r.right && r.top) { + setCursorStyle('nesw-resize'); + } else if (r.left || r.right) { + setCursorStyle('ew-resize'); + } else if (r.top || r.bottom) { + setCursorStyle('ns-resize'); + } + } else if (brush.isInsideBrush(offset_css)) { + setCursorStyle('grabbable'); + } else if (coordmap.isInPanelCss(offset_css, expandPixels)) { + setCursorStyle('crosshair'); + } else { + setCursorStyle(null); + } + } + } // mousemove handlers while brushing or dragging + + + function mousemoveBrushing(e) { + brush.brushTo(coordmap.mouseOffsetCss(e)); + brushInfoSender.normalCall(); + } + + function mousemoveDragging(e) { + brush.dragTo(coordmap.mouseOffsetCss(e)); + brushInfoSender.normalCall(); + } + + function mousemoveResizing(e) { + brush.resizeTo(coordmap.mouseOffsetCss(e)); + brushInfoSender.normalCall(); + } // mouseup handlers while brushing or dragging + + + function mouseupBrushing(e) { + // Listen for left mouse button only + if (e.which !== 1) return; + $(document).off('mousemove.image_brush').off('mouseup.image_brush'); + brush.up(coordmap.mouseOffsetCss(e)); + brush.stopBrushing(); + setCursorStyle('crosshair'); // If the brush didn't go anywhere, hide the brush, clear value, + // and return. + + if (brush.down().x === brush.up().x && brush.down().y === brush.up().y) { + brush.reset(); + brushInfoSender.immediateCall(); + return; + } // Send info immediately on mouseup, but only if needed. If we don't + // do the pending check, we might send the same data twice (with + // with difference nonce). + + + if (brushInfoSender.isPending()) brushInfoSender.immediateCall(); + } + + function mouseupDragging(e) { + // Listen for left mouse button only + if (e.which !== 1) return; + $(document).off('mousemove.image_brush').off('mouseup.image_brush'); + brush.up(coordmap.mouseOffsetCss(e)); + brush.stopDragging(); + setCursorStyle('grabbable'); + if (brushInfoSender.isPending()) brushInfoSender.immediateCall(); + } + + function mouseupResizing(e) { + // Listen for left mouse button only + if (e.which !== 1) return; + $(document).off('mousemove.image_brush').off('mouseup.image_brush'); + brush.up(coordmap.mouseOffsetCss(e)); + brush.stopResizing(); + if (brushInfoSender.isPending()) brushInfoSender.immediateCall(); + } // Brush maintenance: When an image is re-rendered, the brush must either + // be removed (if brushResetOnNew) or imported (if !brushResetOnNew). The + // "mostRecentBrush" bit is to ensure that when multiple outputs share the + // same brush ID, inactive brushes don't send null values up to the server. + // This should be called when the img (not the el) is reset + + + function onResetImg() { + if (opts.brushResetOnNew) { + if ($el.data("mostRecentBrush")) { + brush.reset(); + brushInfoSender.immediateCall(); + } + } + } + + if (!opts.brushResetOnNew) { + if ($el.data("mostRecentBrush")) { + // Importing an old brush must happen after the image data has loaded + // and the DOM element has the updated size. If importOldBrush() + // is called before this happens, then the css-img coordinate mappings + // will give the wrong result, and the brush will have the wrong + // position. + // + // jcheng 09/26/2018: This used to happen in img.onLoad, but recently + // we moved to all brush initialization moving to img.onLoad so this + // logic can be executed inline. + brush.importOldBrush(); + brushInfoSender.immediateCall(); + } + } + + function onResize() { + brush.onResize(); + brushInfoSender.immediateCall(); + } + + return { + mousedown: mousedown, + mousemove: mousemove, + onResetImg: onResetImg, + onResize: onResize + }; + }; // Returns an object that represents the state of the brush. This gets wrapped + // in a brushHandler, which provides various event listeners. + + + imageutils.createBrush = function ($el, opts, coordmap, expandPixels) { + // Number of pixels outside of brush to allow start resizing + var resizeExpand = 10; + var el = $el[0]; + var $div = null; // The div representing the brush + + var state = {}; // Aliases for conciseness + + var cssToImg = coordmap.scaleCssToImg; + var imgToCss = coordmap.scaleImgToCss; + reset(); + + function reset() { + // Current brushing/dragging/resizing state + state.brushing = false; + state.dragging = false; + state.resizing = false; // Offset of last mouse down and up events (in CSS pixels) + + state.down = { + x: NaN, + y: NaN + }; + state.up = { + x: NaN, + y: NaN + }; // Which side(s) we're currently resizing + + state.resizeSides = { + left: false, + right: false, + top: false, + bottom: false + }; // Bounding rectangle of the brush, in CSS pixel and data dimensions. We + // need to record data dimensions along with pixel dimensions so that when + // a new plot is sent, we can re-draw the brush div with the appropriate + // coords. + + state.boundsCss = { + xmin: NaN, + xmax: NaN, + ymin: NaN, + ymax: NaN + }; + state.boundsData = { + xmin: NaN, + xmax: NaN, + ymin: NaN, + ymax: NaN + }; // Panel object that the brush is in + + state.panel = null; // The bounds at the start of a drag/resize (in CSS pixels) + + state.changeStartBounds = { + xmin: NaN, + xmax: NaN, + ymin: NaN, + ymax: NaN + }; + if ($div) $div.remove(); + } // If there's an existing brush div, use that div to set the new brush's + // settings, provided that the x, y, and panel variables have the same names, + // and there's a panel with matching panel variable values. + + + function importOldBrush() { + var oldDiv = $el.find('#' + el.id + '_brush'); + if (oldDiv.length === 0) return; + var oldBoundsData = oldDiv.data('bounds-data'); + var oldPanel = oldDiv.data('panel'); + if (!oldBoundsData || !oldPanel) return; // Find a panel that has matching vars; if none found, we can't restore. + // The oldPanel and new panel must match on their mapping vars, and the + // values. + + for (var i = 0; i < coordmap.panels.length; i++) { + var curPanel = coordmap.panels[i]; + + if (equal(oldPanel.mapping, curPanel.mapping) && equal(oldPanel.panel_vars, curPanel.panel_vars)) { + // We've found a matching panel + state.panel = coordmap.panels[i]; + break; + } + } // If we didn't find a matching panel, remove the old div and return + + + if (state.panel === null) { + oldDiv.remove(); + return; + } + + $div = oldDiv; + boundsData(oldBoundsData); + updateDiv(); + } // This will reposition the brush div when the image is resized, maintaining + // the same data coordinates. Note that the "resize" here refers to the + // wrapper div/img being resized; elsewhere, "resize" refers to the brush + // div being resized. + + + function onResize() { + var bounds_data = boundsData(); // Check to see if we have valid boundsData + + for (var val in bounds_data) { + if (isnan(bounds_data[val])) return; + } + + boundsData(bounds_data); + updateDiv(); + } // Return true if the offset is inside min/max coords + + + function isInsideBrush(offset_css) { + var bounds = state.boundsCss; + return offset_css.x <= bounds.xmax && offset_css.x >= bounds.xmin && offset_css.y <= bounds.ymax && offset_css.y >= bounds.ymin; + } // Return true if offset is inside a region to start a resize + + + function isInResizeArea(offset_css) { + var sides = whichResizeSides(offset_css); + return sides.left || sides.right || sides.top || sides.bottom; + } // Return an object representing which resize region(s) the cursor is in. + + + function whichResizeSides(offset_css) { + var b = state.boundsCss; // Bounds with expansion + + var e = { + xmin: b.xmin - resizeExpand, + xmax: b.xmax + resizeExpand, + ymin: b.ymin - resizeExpand, + ymax: b.ymax + resizeExpand + }; + var res = { + left: false, + right: false, + top: false, + bottom: false + }; + + if ((opts.brushDirection === 'xy' || opts.brushDirection === 'x') && offset_css.y <= e.ymax && offset_css.y >= e.ymin) { + if (offset_css.x < b.xmin && offset_css.x >= e.xmin) res.left = true;else if (offset_css.x > b.xmax && offset_css.x <= e.xmax) res.right = true; + } + + if ((opts.brushDirection === 'xy' || opts.brushDirection === 'y') && offset_css.x <= e.xmax && offset_css.x >= e.xmin) { + if (offset_css.y < b.ymin && offset_css.y >= e.ymin) res.top = true;else if (offset_css.y > b.ymax && offset_css.y <= e.ymax) res.bottom = true; + } + + return res; + } // Sets the bounds of the brush (in CSS pixels), given a box and optional + // panel. This will fit the box bounds into the panel, so we don't brush + // outside of it. This knows whether we're brushing in the x, y, or xy + // directions, and sets bounds accordingly. If no box is passed in, just + // return current bounds. + + + function boundsCss(box_css) { + if (box_css === undefined) { + return $.extend({}, state.boundsCss); + } + + var min_css = { + x: box_css.xmin, + y: box_css.ymin + }; + var max_css = { + x: box_css.xmax, + y: box_css.ymax + }; + var panel = state.panel; + var panelBounds_img = panel.range; + + if (opts.brushClip) { + min_css = imgToCss(panel.clipImg(cssToImg(min_css))); + max_css = imgToCss(panel.clipImg(cssToImg(max_css))); + } + + if (opts.brushDirection === 'xy') {// No change + } else if (opts.brushDirection === 'x') { + // Extend top and bottom of plotting area + min_css.y = imgToCss({ + y: panelBounds_img.top + }).y; + max_css.y = imgToCss({ + y: panelBounds_img.bottom + }).y; + } else if (opts.brushDirection === 'y') { + min_css.x = imgToCss({ + x: panelBounds_img.left + }).x; + max_css.x = imgToCss({ + x: panelBounds_img.right + }).x; + } + + state.boundsCss = { + xmin: min_css.x, + xmax: max_css.x, + ymin: min_css.y, + ymax: max_css.y + }; // Positions in data space + + var min_data = state.panel.scaleImgToData(cssToImg(min_css)); + var max_data = state.panel.scaleImgToData(cssToImg(max_css)); // For reversed scales, the min and max can be reversed, so use findBox + // to ensure correct order. + + state.boundsData = imageutils.findBox(min_data, max_data); // Round to 14 significant digits to avoid spurious changes in FP values + // (#1634). + + state.boundsData = mapValues(state.boundsData, function (val) { + return roundSignif(val, 14); + }); // We also need to attach the data bounds and panel as data attributes, so + // that if the image is re-sent, we can grab the data bounds to create a new + // brush. This should be fast because it doesn't actually modify the DOM. + + $div.data('bounds-data', state.boundsData); + $div.data('panel', state.panel); + return undefined; + } // Get or set the bounds of the brush using coordinates in the data space. + + + function boundsData(box_data) { + if (box_data === undefined) { + return $.extend({}, state.boundsData); + } + + var box_css = imgToCss(state.panel.scaleDataToImg(box_data)); // Round to 13 significant digits to avoid spurious changes in FP values + // (#2197). + + box_css = mapValues(box_css, function (val) { + return roundSignif(val, 13); + }); // The scaling function can reverse the direction of the axes, so we need to + // find the min and max again. + + boundsCss({ + xmin: Math.min(box_css.xmin, box_css.xmax), + xmax: Math.max(box_css.xmin, box_css.xmax), + ymin: Math.min(box_css.ymin, box_css.ymax), + ymax: Math.max(box_css.ymin, box_css.ymax) + }); + return undefined; + } + + function getPanel() { + return state.panel; + } // Add a new div representing the brush. + + + function addDiv() { + if ($div) $div.remove(); // Start hidden; we'll show it when movement occurs + + $div = $(document.createElement('div')).attr('id', el.id + '_brush').css({ + 'background-color': opts.brushFill, + 'opacity': opts.brushOpacity, + 'pointer-events': 'none', + 'position': 'absolute' + }).hide(); + var borderStyle = '1px solid ' + opts.brushStroke; + + if (opts.brushDirection === 'xy') { + $div.css({ + 'border': borderStyle + }); + } else if (opts.brushDirection === 'x') { + $div.css({ + 'border-left': borderStyle, + 'border-right': borderStyle + }); + } else if (opts.brushDirection === 'y') { + $div.css({ + 'border-top': borderStyle, + 'border-bottom': borderStyle + }); + } + + $el.append($div); + $div.offset({ + x: 0, + y: 0 + }).width(0).outerHeight(0); + } // Update the brush div to reflect the current brush bounds. + + + function updateDiv() { + // Need parent offset relative to page to calculate mouse offset + // relative to page. + var img_offset_css = findOrigin($el.find("img")); + var b = state.boundsCss; + $div.offset({ + top: img_offset_css.y + b.ymin, + left: img_offset_css.x + b.xmin + }).outerWidth(b.xmax - b.xmin + 1).outerHeight(b.ymax - b.ymin + 1); + } + + function down(offset_css) { + if (offset_css === undefined) return state.down; + state.down = offset_css; + return undefined; + } + + function up(offset_css) { + if (offset_css === undefined) return state.up; + state.up = offset_css; + return undefined; + } + + function isBrushing() { + return state.brushing; + } + + function startBrushing() { + state.brushing = true; + addDiv(); + state.panel = coordmap.getPanelCss(state.down, expandPixels); + boundsCss(imageutils.findBox(state.down, state.down)); + updateDiv(); + } + + function brushTo(offset_css) { + boundsCss(imageutils.findBox(state.down, offset_css)); + $div.show(); + updateDiv(); + } + + function stopBrushing() { + state.brushing = false; // Save the final bounding box of the brush + + boundsCss(imageutils.findBox(state.down, state.up)); + } + + function isDragging() { + return state.dragging; + } + + function startDragging() { + state.dragging = true; + state.changeStartBounds = $.extend({}, state.boundsCss); + } + + function dragTo(offset_css) { + // How far the brush was dragged + var dx = offset_css.x - state.down.x; + var dy = offset_css.y - state.down.y; // Calculate what new positions would be, before clipping. + + var start = state.changeStartBounds; + var newBounds_css = { + xmin: start.xmin + dx, + xmax: start.xmax + dx, + ymin: start.ymin + dy, + ymax: start.ymax + dy + }; // Clip to the plotting area + + if (opts.brushClip) { + var panelBounds_img = state.panel.range; + var newBounds_img = cssToImg(newBounds_css); // Convert to format for shiftToRange + + var xvals_img = [newBounds_img.xmin, newBounds_img.xmax]; + var yvals_img = [newBounds_img.ymin, newBounds_img.ymax]; + xvals_img = imageutils.shiftToRange(xvals_img, panelBounds_img.left, panelBounds_img.right); + yvals_img = imageutils.shiftToRange(yvals_img, panelBounds_img.top, panelBounds_img.bottom); // Convert back to bounds format + + newBounds_css = imgToCss({ + xmin: xvals_img[0], + xmax: xvals_img[1], + ymin: yvals_img[0], + ymax: yvals_img[1] + }); + } + + boundsCss(newBounds_css); + updateDiv(); + } + + function stopDragging() { + state.dragging = false; + } + + function isResizing() { + return state.resizing; + } + + function startResizing() { + state.resizing = true; + state.changeStartBounds = $.extend({}, state.boundsCss); + state.resizeSides = whichResizeSides(state.down); + } + + function resizeTo(offset_css) { + // How far the brush was dragged + var d_css = { + x: offset_css.x - state.down.x, + y: offset_css.y - state.down.y + }; + var d_img = cssToImg(d_css); // Calculate what new positions would be, before clipping. + + var b_img = cssToImg(state.changeStartBounds); + var panelBounds_img = state.panel.range; + + if (state.resizeSides.left) { + var xmin_img = imageutils.shiftToRange(b_img.xmin + d_img.x, panelBounds_img.left, b_img.xmax)[0]; + b_img.xmin = xmin_img; + } else if (state.resizeSides.right) { + var xmax_img = imageutils.shiftToRange(b_img.xmax + d_img.x, b_img.xmin, panelBounds_img.right)[0]; + b_img.xmax = xmax_img; + } + + if (state.resizeSides.top) { + var ymin_img = imageutils.shiftToRange(b_img.ymin + d_img.y, panelBounds_img.top, b_img.ymax)[0]; + b_img.ymin = ymin_img; + } else if (state.resizeSides.bottom) { + var ymax_img = imageutils.shiftToRange(b_img.ymax + d_img.y, b_img.ymin, panelBounds_img.bottom)[0]; + b_img.ymax = ymax_img; + } + + boundsCss(imgToCss(b_img)); + updateDiv(); + } + + function stopResizing() { + state.resizing = false; + } + + return { + reset: reset, + importOldBrush: importOldBrush, + isInsideBrush: isInsideBrush, + isInResizeArea: isInResizeArea, + whichResizeSides: whichResizeSides, + onResize: onResize, + // A callback when the wrapper div or img is resized. + boundsCss: boundsCss, + boundsData: boundsData, + getPanel: getPanel, + down: down, + up: up, + isBrushing: isBrushing, + startBrushing: startBrushing, + brushTo: brushTo, + stopBrushing: stopBrushing, + isDragging: isDragging, + startDragging: startDragging, + dragTo: dragTo, + stopDragging: stopDragging, + isResizing: isResizing, + startResizing: startResizing, + resizeTo: resizeTo, + stopResizing: stopResizing + }; + }; + + exports.resetBrush = function (brushId) { + exports.setInputValue(brushId, null); + imageOutputBinding.find(document).trigger("shiny-internal:brushed", { + brushId: brushId, + outputId: null + }); + }; // ----------------------------------------------------------------------- + // Utility functions for finding dimensions and locations of DOM elements + // ----------------------------------------------------------------------- + // Returns the ratio that an element has been scaled (for example, by CSS + // transforms) in the x and y directions. + + + function findScalingRatio($el) { + var boundingRect = $el[0].getBoundingClientRect(); + return { + x: boundingRect.width / $el.outerWidth(), + y: boundingRect.height / $el.outerHeight() + }; + } + + function findOrigin($el) { + var offset = $el.offset(); + var scaling_ratio = findScalingRatio($el); // Find the size of the padding and border, for the top and left. This is + // before any transforms. + + var paddingBorder = { + left: parseInt($el.css("border-left-width")) + parseInt($el.css("padding-left")), + top: parseInt($el.css("border-top-width")) + parseInt($el.css("padding-top")) + }; // offset() returns the upper left corner of the element relative to the + // page, but it includes padding and border. Here we find the upper left + // of the element, not including padding and border. + + return { + x: offset.left + scaling_ratio.x * paddingBorder.left, + y: offset.top + scaling_ratio.y * paddingBorder.top + }; + } // Find the dimensions of a tag, after transforms, and without padding and + // border. + + + function findDims($el) { + // If there's any padding/border, we need to find the ratio of the actual + // element content compared to the element plus padding and border. + var content_ratio = { + x: $el.width() / $el.outerWidth(), + y: $el.height() / $el.outerHeight() + }; // Get the dimensions of the element _after_ any CSS transforms. This + // includes the padding and border. + + var bounding_rect = $el[0].getBoundingClientRect(); // Dimensions of the element after any CSS transforms, and without + // padding/border. + + return { + x: content_ratio.x * bounding_rect.width, + y: content_ratio.y * bounding_rect.height + }; + } //--------------------------------------------------------------------- + // Source file: ../srcjs/output_binding_html.js + + + var htmlOutputBinding = new OutputBinding(); + $.extend(htmlOutputBinding, { + find: function find(scope) { + return $(scope).find('.shiny-html-output'); + }, + onValueError: function onValueError(el, err) { + exports.unbindAll(el); + this.renderError(el, err); + }, + renderValue: function renderValue(el, data) { + exports.renderContent(el, data); + } + }); + outputBindings.register(htmlOutputBinding, 'shiny.htmlOutput'); + + var renderDependencies = exports.renderDependencies = function (dependencies) { + if (dependencies) { + $.each(dependencies, function (i, dep) { + renderDependency(dep); + }); + } + }; // Render HTML in a DOM element, add dependencies, and bind Shiny + // inputs/outputs. `content` can be null, a string, or an object with + // properties 'html' and 'deps'. + + + exports.renderContent = function (el, content) { + var where = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : "replace"; + + if (where === "replace") { + exports.unbindAll(el); + } + + var html; + var dependencies = []; + + if (content === null) { + html = ''; + } else if (typeof content === 'string') { + html = content; + } else if (_typeof(content) === 'object') { + html = content.html; + dependencies = content.deps || []; + } + + exports.renderHtml(html, el, dependencies, where); + var scope = el; + + if (where === "replace") { + exports.initializeInputs(el); + exports.bindAll(el); + } else { + var $parent = $(el).parent(); + + if ($parent.length > 0) { + scope = $parent; + + if (where === "beforeBegin" || where === "afterEnd") { + var $grandparent = $parent.parent(); + if ($grandparent.length > 0) scope = $grandparent; + } + } + + exports.initializeInputs(scope); + exports.bindAll(scope); + } + }; // Render HTML in a DOM element, inserting singletons into head as needed + + + exports.renderHtml = function (html, el, dependencies) { + var where = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'replace'; + renderDependencies(dependencies); + return singletons.renderHtml(html, el, where); + }; + + var htmlDependencies = {}; + + function registerDependency(name, version) { + htmlDependencies[name] = version; + } // Re-render stylesheet(s) if the dependency has specificially requested it + // and it matches an existing dependency (name and version) + + + function needsRestyle(dep) { + if (!dep.restyle) { + return false; + } + + var names = Object.keys(htmlDependencies); + var idx = names.indexOf(dep.name); + + if (idx === -1) { + return false; + } + + return htmlDependencies[names[idx]] === dep.version; + } // Client-side dependency resolution and rendering + + + function renderDependency(dep) { + var restyle = needsRestyle(dep); + if (htmlDependencies.hasOwnProperty(dep.name) && !restyle) return false; + registerDependency(dep.name, dep.version); + var href = dep.src.href; + var $head = $("head").first(); + + if (dep.meta && !restyle) { + var metas = $.map(asArray(dep.meta), function (obj, idx) { + // only one named pair is expected in obj as it's already been decomposed + var name = Object.keys(obj)[0]; + return $("").attr("name", name).attr("content", obj[name]); + }); + $head.append(metas); + } + + if (dep.stylesheet) { + var links = $.map(asArray(dep.stylesheet), function (stylesheet) { + return $("").attr("href", href + "/" + encodeURI(stylesheet)); + }); + + if (!restyle) { + $head.append(links); + } else { + // This inline