// TODO: Invalid values shouldn't just stop event from propagating, they should // also not be sent to the server if a different input has a validinput event. (function($) { // From Shiny 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); }; } var debouncedSetInputValue = debounce(750, function() { Shiny.setInputValue.apply(Shiny, arguments); }); function resetColors(input) { $(input).css("background-color", ""); $(input).css("color", ""); } function showError(input, message) { resetColors(input); $(input).addClass("is-invalid"); } function clearError(input) { $(input).removeClass("is-invalid"); } function yiq_light(red, green, blue) { return (red * 299 + green * 587 + blue * 114) / 1000 >= 128; } function parseColor(color) { // Drop whitespace: color = color .replace(/\s*,\s*/g, ",") // around commas .replace(/\(\s+/g, "(") // after open-parens .replace(/\s+\)/, ")") // before close-parens .replace(/^\s+/, "") // at the start .replace(/\s+$/, ""); // at the end var keywords = { "aliceblue": "#F0F8FF", "antiquewhite": "#FAEBD7", "aqua": "#00FFFF", "aquamarine": "#7FFFD4", "azure": "#F0FFFF", "beige": "#F5F5DC", "bisque": "#FFE4C4", "black": "#000000", "blanchedalmond": "#FFEBCD", "blue": "#0000FF", "blueviolet": "#8A2BE2", "brown": "#A52A2A", "burlywood": "#DEB887", "cadetblue": "#5F9EA0", "chartreuse": "#7FFF00", "chocolate": "#D2691E", "coral": "#FF7F50", "cornflowerblue": "#6495ED", "cornsilk": "#FFF8DC", "crimson": "#DC143C", "cyan": "#00FFFF", "darkblue": "#00008B", "darkcyan": "#008B8B", "darkgoldenrod": "#B8860B", "darkgray": "#A9A9A9", "darkgreen": "#006400", "darkgrey": "#A9A9A9", "darkkhaki": "#BDB76B", "darkmagenta": "#8B008B", "darkolivegreen": "#556B2F", "darkorange": "#FF8C00", "darkorchid": "#9932CC", "darkred": "#8B0000", "darksalmon": "#E9967A", "darkseagreen": "#8FBC8F", "darkslateblue": "#483D8B", "darkslategray": "#2F4F4F", "darkslategrey": "#2F4F4F", "darkturquoise": "#00CED1", "darkviolet": "#9400D3", "deeppink": "#FF1493", "deepskyblue": "#00BFFF", "dimgray": "#696969", "dimgrey": "#696969", "dodgerblue": "#1E90FF", "firebrick": "#B22222", "floralwhite": "#FFFAF0", "forestgreen": "#228B22", "fuchsia": "#FF00FF", "gainsboro": "#DCDCDC", "ghostwhite": "#F8F8FF", "gold": "#FFD700", "goldenrod": "#DAA520", "gray": "#808080", "green": "#008000", "greenyellow": "#ADFF2F", "grey": "#808080", "honeydew": "#F0FFF0", "hotpink": "#FF69B4", "indianred": "#CD5C5C", "indigo": "#4B0082", "ivory": "#FFFFF0", "khaki": "#F0E68C", "lavender": "#E6E6FA", "lavenderblush": "#FFF0F5", "lawngreen": "#7CFC00", "lemonchiffon": "#FFFACD", "lightblue": "#ADD8E6", "lightcoral": "#F08080", "lightcyan": "#E0FFFF", "lightgoldenrodyellow": "#FAFAD2", "lightgray": "#D3D3D3", "lightgreen": "#90EE90", "lightgrey": "#D3D3D3", "lightpink": "#FFB6C1", "lightsalmon": "#FFA07A", "lightseagreen": "#20B2AA", "lightskyblue": "#87CEFA", "lightslategray": "#778899", "lightslategrey": "#778899", "lightsteelblue": "#B0C4DE", "lightyellow": "#FFFFE0", "lime": "#00FF00", "limegreen": "#32CD32", "linen": "#FAF0E6", "magenta": "#FF00FF", "maroon": "#800000", "mediumaquamarine": "#66CDAA", "mediumblue": "#0000CD", "mediumorchid": "#BA55D3", "mediumpurple": "#9370DB", "mediumseagreen": "#3CB371", "mediumslateblue": "#7B68EE", "mediumspringgreen": "#00FA9A", "mediumturquoise": "#48D1CC", "mediumvioletred": "#C71585", "midnightblue": "#191970", "mintcream": "#F5FFFA", "mistyrose": "#FFE4E1", "moccasin": "#FFE4B5", "navajowhite": "#FFDEAD", "navy": "#000080", "oldlace": "#FDF5E6", "olive": "#808000", "olivedrab": "#6B8E23", "orange": "#FFA500", "orangered": "#FF4500", "orchid": "#DA70D6", "palegoldenrod": "#EEE8AA", "palegreen": "#98FB98", "paleturquoise": "#AFEEEE", "palevioletred": "#DB7093", "papayawhip": "#FFEFD5", "peachpuff": "#FFDAB9", "peru": "#CD853F", "pink": "#FFC0CB", "plum": "#DDA0DD", "powderblue": "#B0E0E6", "purple": "#800080", "rebeccapurple": "#663399", "red": "#FF0000", "rosybrown": "#BC8F8F", "royalblue": "#4169E1", "saddlebrown": "#8B4513", "salmon": "#FA8072", "sandybrown": "#F4A460", "seagreen": "#2E8B57", "seashell": "#FFF5EE", "sienna": "#A0522D", "silver": "#C0C0C0", "skyblue": "#87CEEB", "slateblue": "#6A5ACD", "slategray": "#708090", "slategrey": "#708090", "snow": "#FFFAFA", "springgreen": "#00FF7F", "steelblue": "#4682B4", "tan": "#D2B48C", "teal": "#008080", "thistle": "#D8BFD8", "tomato": "#FF6347", "turquoise": "#40E0D0", "violet": "#EE82EE", "wheat": "#F5DEB3", "white": "#FFFFFF", "whitesmoke": "#F5F5F5", "yellow": "#FFFF00", "yellowgreen": "#9ACD32" }; if (keywords[color]) { color = keywords[color]; } var m; m = /^#([A-Za-z0-9]{2})([A-Za-z0-9]{2})([A-Za-z0-9]{2})$/.exec(color); if (m) { return { red: parseInt(m[1], 16), green: parseInt(m[2], 16), blue: parseInt(m[3], 16) }; } m = /^#([A-Za-z0-9]{2})([A-Za-z0-9]{2})([A-Za-z0-9]{2})([A-Za-z0-9]{2})$/.exec(color); if (m) { return { red: parseInt(m[1], 16), green: parseInt(m[2], 16), blue: parseInt(m[3], 16), alpha: parseInt(m[4], 16) / 255 }; } m = /^#([A-Za-z0-9])([A-Za-z0-9])([A-Za-z0-9])$/.exec(color); if (m) { return { red: parseInt(m[1], 16) * 0x11, green: parseInt(m[2], 16) * 0x11, blue: parseInt(m[3], 16) * 0x11 }; } m = /^#([A-Za-z0-9])([A-Za-z0-9])([A-Za-z0-9])([A-Za-z0-9])$/.exec(color); if (m) { return { red: parseInt(m[1], 16) * 0x11, green: parseInt(m[2], 16) * 0x11, blue: parseInt(m[3], 16) * 0x11, alpha: parseInt(m[4], 16) * 0x11 / 255 }; } m = /^rgba?\((\d+),(\d+),(\d+)\)$/.exec(color); if (m) { return { red: parseInt(m[1]), green: parseInt(m[2]), blue: parseInt(m[3]) }; } m = /^rgba?\((\d+),(\d+),(\d+),(\d*\.?\d*)\)$/.exec(color); if (m) { if (!isNaN(parseFloat(m[4]))) { return { red: parseInt(m[1]), green: parseInt(m[2]), blue: parseInt(m[3]), alpha: parseFloat(m[4]) }; } else { return { red: parseInt(m[1]), green: parseInt(m[2]), blue: parseInt(m[3]) }; } } return null; } function colorsEquivalent(colorStrA, colorStrB) { var colorA = parseColor(colorStrA); var colorB = parseColor(colorStrB); if (!colorA || !colorB) { // Don't consider invalid colors equivalent return false; } return JSON.stringify(colorA) === JSON.stringify(colorB); } function syncColors(inputEl) { var color = inputEl.value; // TODO: Trim color? clearError(inputEl); var parsedColor = parseColor(color); if (!parsedColor) { showError(inputEl, null); return false; } var { red, green, blue } = parsedColor; var text_color = yiq_light(red, green, blue) ? "#333333" : "#FFFFFF"; $(inputEl).css("color", text_color); $(inputEl).css("background-color", color); return color; } $(document).on("colorpickerChange.bsthemer", ".bs-theme-value-color", function(e) { var newColor = $(e.target).colorpicker("getValue"); if (colorsEquivalent(newColor, e.target.value)) { return; } e.target.value = newColor; syncColors(e.target); $(e.target).trigger("validinput"); }); $(document).on("input.bsthemer", ".bs-theme-value-color", function(e) { var origValue = e.target.value; var color = syncColors(e.target); if (color) { $(e.target).colorpicker("setValue", color); // I can't stop "setValue" from modifying e.target.value, but I can // immediately undo it. For example, setting color to #FFFFAA, then // backspacing it to #FFF, without this change it's expanded out to // #FFFFFF automatically which is disruptive to the user while typing. e.target.value = origValue; $(e.target).trigger("validinput"); } }); function initColorInput(el) { var origValue = el.value; syncColors(el); $(el).colorpicker({ autoInputFallback: false }); // needed to prevent the colorpicker() call we just performed // from normalizing the value, e.g. #fff becomes #FFFFFF, which // then makes it hard to know which values actually changed el.value = origValue; // bootstrap-colorpicker is too aggressive in handling changes to the // text input. It replaces #ABC with #AABBCC, and it's very hard to // make it stop. Instead, we just stop it from listening to the input // and handle the events ourselves. $(el).off("keyup.colorpicker"); $(el).off("change.colorpicker"); } $(document).on("change.bsthemer click.bsthemer", ".bs-theme-value-bool", function(e) { $(e.target).trigger("validinput"); }); function initBoolInput(el) { } $(document).on("input.bsthemer", ".bs-theme-value-str", function(e) { $(e.target).trigger("validinput"); }); function initStrInput(el) { // Starting with BS5, some Sass variables hold CSS variables (e.g. $font-family-base) if (el.value.match(/var\(--/)) { const bodyStyles = getComputedStyle(document.body); const cssVar = el.value.replace('var(', '').replace(')', ''); const val = bodyStyles.getPropertyValue(cssVar); el.value = val; } } $(document).on("change", ".bs-theme-value-select", function(e) { var select = $(e.target); if (select.data("id") === "preset") { Shiny.setInputValue("bs_theme_preset", select.val()); } else { select.trigger("validinput"); } }); $(document).on("change", "#bsthemer-dark-mode", function(ev) { const colorMode = ev.target.checked ? "dark" : "light"; document.documentElement.dataset.bsTheme = colorMode; $(window).resize(); }); function initSelectInput(el) { } $(document).on("input.bsthemer", ".bs-theme-value-length", function(e) { // TODO: Maybe validate length? $(e.target).trigger("validinput"); }); function initLengthInput(el) { } $(function() { $(".bs-theme-value-color").each(function(i, el) { initColorInput(el); }); $(".bs-theme-value-bool").each(function(i, el) { initBoolInput(el); }); $(".bs-theme-value-str").each(function(i, el) { initStrInput(el); }); $(".bs-theme-value-select").each(function(i, el) { initSelectInput(el); }); $(".bs-theme-value-length").each(function(i, el) { initLengthInput(el); }); }); $(document).on("validinput", ".bs-theme-value", function(e) { if (syncing) return; var values = {}; $(".bs-theme-value-color, .bs-theme-value-str, .bs-theme-value-select, .bs-theme-value-length").each(function() { values[$(this).data("id")] = $(this).val(); }); $(".bs-theme-value-bool").each(function() { values[$(this).data("id")] = this.checked; }); Object.keys(values).forEach(function(key) { if (typeof(values[key]) === "string" && /^\s*$/.test(values[key])) { // Empty strings cause crashes in sass; nulls are safely omitted values[key] = null; } }); debouncedSetInputValue.call(Shiny, "bs_theme_vars", JSON.stringify(values)); }); // When the Bootswatch theme changes, apply new input defaults var syncing = false; Shiny.addCustomMessageHandler("bs-themer-preset", function(msg) { syncing = true; var vals = msg.values; var keys = Object.keys(vals); var themer = $("#bsthemerContainer"); for (var i = 0; i < keys.length; i++) { var key = keys[i]; var val = vals[key]; var input = themer.find(".bs-theme-value[data-id='" + key + "']"); if (input.hasClass("bs-theme-value-bool")) { input.prop('checked', JSON.parse(val)); } else { input.val(val); } if (input.data("colorpicker")) { input.colorpicker("setValue", val); syncColors(input[0]); } } syncing = false; }) // Initialize tooltips in the themer container $(document).ready(function() { document .querySelectorAll('#bsthemerContainer [data-bs-toggle="tooltip"]') .forEach((tooltipNode) => new window.bootstrap.Tooltip(tooltipNode)); }); /*** Begin dragging logic ***/ var active_move_grabber = null; var active_move_target = null; var active_move_offset = null; $(document).on("pointerdown", ".move-grabber", function(e) { e.preventDefault(); active_move_grabber = e.target; active_move_target = $(document).find(e.target.dataset.target)[0]; active_move_offset = { x: e.clientX - active_move_target.offsetLeft, y: e.clientY - active_move_target.offsetTop }; if (active_move_grabber.setPointerCapture) { active_move_grabber.setPointerCapture(e.pointerId); } }); $(document).on("pointermove", function(e) { if (!active_move_grabber) { return; } active_move_target.style.left = (e.clientX - active_move_offset.x) + "px"; active_move_target.style.top = (e.clientY - active_move_offset.y) + "px"; active_move_target.style.right = "auto"; active_move_target.style.bottom = "auto"; constrain(active_move_target); }); $(document).on("pointerup", ".move-grabber", function(e) { if (!active_move_grabber) { return; } if (active_move_grabber.setPointerCapture) { active_move_grabber.releasePointerCapture(e.pointerId); } active_move_grabber = null; active_move_target = null; active_move_offset = null; }); /** * Takes the given absolutely positioned element, and determines which corner * of its offsetParent it's closest to (unless its offsetParent is a body tag, * in which case we use the browser's viewport). The element's position is * anchored to its closest corner, so that resizing the browser causes the * element to stay in view. It also makes sure that the entire element is * visible. */ function constrain(el) { var parent = el.offsetParent; // RStudio viewer gives null offsetParent?? if (!parent || parent.tagName === "BODY") { // If the element is parented by the body, look at the top-level html tag // instead; its clientWidth/Height is the browser's viewport, a special // case. parent = document.documentElement; } var parentBounds = { top: 0, right: parent.clientWidth, bottom: parent.clientHeight, left: 0 }; var elBounds = { top: el.offsetTop, right: parentBounds.right - (el.offsetLeft + el.offsetWidth), bottom: parentBounds.bottom - (el.offsetTop + el.offsetHeight), left: el.offsetLeft }; if (elBounds.top <= elBounds.bottom) { el.style.top = Math.max(0, elBounds.top) + "px"; el.style.bottom = "auto"; } else { el.style.top = "auto"; el.style.bottom = Math.max(0, elBounds.bottom) + "px"; } if (elBounds.left <= elBounds.right) { el.style.left = Math.max(0, elBounds.left) + "px"; el.style.right = "auto"; } else { el.style.left = "auto"; el.style.right = Math.max(0, elBounds.right) + "px"; } } /*** End dragging logic ***/ })(window.jQuery);