2025-01-12 04:36:52 +08:00

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);