412 lines
15 KiB
JavaScript
412 lines
15 KiB
JavaScript
// 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);
|