mirror of
https://github.com/rstudio/shiny.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
10 Commits
non-blocki
...
fix/condit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da8c86a75 | ||
|
|
2c077b96f9 | ||
|
|
22e0a0b0dd | ||
|
|
ac2c33a5fc | ||
|
|
ee4568f609 | ||
|
|
1fc858440d | ||
|
|
d4882a9426 | ||
|
|
68032b46e5 | ||
|
|
719f3c8b3b | ||
|
|
e07298a728 |
25
NEWS.md
25
NEWS.md
@@ -1,16 +1,35 @@
|
||||
# shiny (development version)
|
||||
|
||||
## Bug fixes
|
||||
|
||||
* `conditionalPanel()` no longer briefly flashes its contents on app start
|
||||
when the condition is initially `FALSE`. (#3505)
|
||||
|
||||
## Improvements
|
||||
|
||||
* Output resize/visibility detection now uses native browser observers
|
||||
(`ResizeObserver`, `IntersectionObserver`) instead of relying on jQuery
|
||||
`shown`/`hidden` events and `window.resize`. This makes Shiny's client-side
|
||||
output-info pipeline (image/plot sizing, hidden-state tracking, theme
|
||||
reporting) work automatically in any layout — including CSS-only show/hide,
|
||||
third-party tab components, and non-Bootstrap frameworks — without requiring
|
||||
custom event hooks. (#3682)
|
||||
|
||||
# shiny 1.13.0
|
||||
|
||||
## New features
|
||||
|
||||
* Shiny now supports interactive breakpoints when used with Ark (e.g. in Positron). (#4352)
|
||||
* Shiny now supports interactive breakpoints when used with Ark (e.g. in
|
||||
Positron). (#4352)
|
||||
|
||||
## Bug fixes and minor improvements
|
||||
|
||||
* Stack traces from render functions (e.g., `renderPlot()`, `renderDataTable()`) now hide internal Shiny rendering pipeline frames, making error messages cleaner and more focused on user code. (#4358)
|
||||
* Stack traces from render functions (e.g., `renderPlot()`, `renderDataTable()`)
|
||||
now hide internal Shiny rendering pipeline frames, making error messages
|
||||
cleaner and more focused on user code. (#4358)
|
||||
|
||||
* Fixed an issue with `actionLink()` that extended the link underline to whitespace around the text. (#4348)
|
||||
* Fixed an issue with `actionLink()` that extended the link underline to
|
||||
whitespace around the text. (#4348)
|
||||
|
||||
|
||||
# shiny 1.12.1
|
||||
|
||||
@@ -41,7 +41,7 @@ export default [{
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
project: ["./tsconfig.eslint.json"],
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -230,6 +230,10 @@
|
||||
this.args = args;
|
||||
this.$invoke();
|
||||
}
|
||||
cancel() {
|
||||
this.$clearTimer();
|
||||
this.args = null;
|
||||
}
|
||||
isPending() {
|
||||
return this.timerId !== null;
|
||||
}
|
||||
@@ -250,7 +254,7 @@
|
||||
};
|
||||
function debounce(threshold, func) {
|
||||
let timerId = null;
|
||||
return function thisFunc(...args) {
|
||||
const debounced = function thisFunc(...args) {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
@@ -261,6 +265,13 @@
|
||||
func.apply(thisFunc, args);
|
||||
}, threshold);
|
||||
};
|
||||
debounced.cancel = function() {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
return debounced;
|
||||
}
|
||||
|
||||
// srcts/src/time/invoke.ts
|
||||
@@ -331,22 +342,58 @@
|
||||
}
|
||||
};
|
||||
|
||||
// srcts/src/shiny/sendImageSize.ts
|
||||
var SendImageSize = class {
|
||||
setImageSend(inputBatchSender, doSendImageSize) {
|
||||
const sendImageSizeDebouncer = new Debouncer(null, doSendImageSize, 0);
|
||||
// srcts/src/shiny/sendOutputInfo.ts
|
||||
var _pendingObserverCallbacks;
|
||||
var SendOutputInfo = class {
|
||||
constructor() {
|
||||
__privateAdd(this, _pendingObserverCallbacks, /* @__PURE__ */ new Set());
|
||||
}
|
||||
setSendMethod(inputBatchSender, doSendOutputInfo) {
|
||||
const sendOutputInfoDebouncer = new Debouncer(null, doSendOutputInfo, 0);
|
||||
this.regular = function() {
|
||||
sendImageSizeDebouncer.normalCall();
|
||||
sendOutputInfoDebouncer.normalCall();
|
||||
};
|
||||
inputBatchSender.lastChanceCallback.push(function() {
|
||||
if (sendImageSizeDebouncer.isPending())
|
||||
sendImageSizeDebouncer.immediateCall();
|
||||
inputBatchSender.lastChanceCallback.push(() => {
|
||||
__privateGet(this, _pendingObserverCallbacks).forEach((callback) => callback.flush());
|
||||
if (sendOutputInfoDebouncer.isPending())
|
||||
sendOutputInfoDebouncer.immediateCall();
|
||||
});
|
||||
this.transitioned = debounce(200, this.regular);
|
||||
return sendImageSizeDebouncer;
|
||||
return sendOutputInfoDebouncer;
|
||||
}
|
||||
createObserverCallback(delayMs, callback) {
|
||||
const debouncer = new Debouncer(
|
||||
null,
|
||||
() => {
|
||||
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
|
||||
callback();
|
||||
},
|
||||
delayMs
|
||||
);
|
||||
const observerCallback = Object.assign(
|
||||
() => {
|
||||
__privateGet(this, _pendingObserverCallbacks).add(observerCallback);
|
||||
debouncer.normalCall();
|
||||
},
|
||||
{
|
||||
cancel: () => {
|
||||
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
|
||||
debouncer.cancel();
|
||||
},
|
||||
flush: () => {
|
||||
__privateGet(this, _pendingObserverCallbacks).delete(observerCallback);
|
||||
if (debouncer.isPending()) {
|
||||
debouncer.immediateCall();
|
||||
}
|
||||
},
|
||||
isPending: () => debouncer.isPending()
|
||||
}
|
||||
);
|
||||
return observerCallback;
|
||||
}
|
||||
};
|
||||
var sendImageSizeFns = new SendImageSize();
|
||||
_pendingObserverCallbacks = new WeakMap();
|
||||
var sendOutputInfoFns = new SendOutputInfo();
|
||||
|
||||
// srcts/src/shiny/singletons.ts
|
||||
var import_jquery4 = __toESM(require_jquery());
|
||||
@@ -543,7 +590,7 @@
|
||||
$head.append(newStyle);
|
||||
oldStyle.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendImageSizeFns.transitioned();
|
||||
sendOutputInfoFns.transitioned();
|
||||
};
|
||||
xhr.send();
|
||||
};
|
||||
@@ -578,7 +625,7 @@
|
||||
$dummyEl.one("transitionend", () => {
|
||||
$dummyEl.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendImageSizeFns.transitioned();
|
||||
sendOutputInfoFns.transitioned();
|
||||
});
|
||||
(0, import_jquery5.default)(document.body).append($dummyEl);
|
||||
const color = "#" + Math.floor(Math.random() * 16777215).toString(16);
|
||||
@@ -811,6 +858,15 @@
|
||||
}
|
||||
return x2;
|
||||
}
|
||||
function isVisible(el) {
|
||||
if (el.offsetWidth !== 0 || el.offsetHeight !== 0) {
|
||||
return true;
|
||||
}
|
||||
if (getStyle(el, "display") === "none") {
|
||||
return false;
|
||||
}
|
||||
return el.parentElement ? isVisible(el.parentElement) : true;
|
||||
}
|
||||
function padZeros(n4, digits) {
|
||||
let str = n4.toString();
|
||||
while (str.length < digits) str = "0" + str;
|
||||
@@ -5749,12 +5805,7 @@ ${duplicateIdMsg}`;
|
||||
}
|
||||
return inputItems;
|
||||
}
|
||||
async function bindOutputs({
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
outputBindings,
|
||||
outputIsRecalculating
|
||||
}, scope = document.documentElement) {
|
||||
async function bindOutputs({ outputBindings, outputIsRecalculating }, scope = document.documentElement) {
|
||||
const $scope = (0, import_jquery35.default)(scope);
|
||||
const bindings = outputBindings.getBindings();
|
||||
for (let i5 = 0; i5 < bindings.length; i5++) {
|
||||
@@ -5769,7 +5820,6 @@ ${duplicateIdMsg}`;
|
||||
if ($el.hasClass("shiny-bound-output")) {
|
||||
continue;
|
||||
}
|
||||
maybeAddThemeObserver(el);
|
||||
const bindingAdapter = new OutputBindingAdapter(el, binding);
|
||||
await shinyAppBindOutput(id, bindingAdapter);
|
||||
$el.data("shiny-output-binding", bindingAdapter);
|
||||
@@ -5787,8 +5837,7 @@ ${duplicateIdMsg}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
}
|
||||
function unbindInputs(scope = document.documentElement, includeSelf = false) {
|
||||
const inputs = (0, import_jquery35.default)(scope).find(".shiny-bound-input").toArray();
|
||||
@@ -5811,7 +5860,7 @@ ${duplicateIdMsg}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
function unbindOutputs({ sendOutputHiddenState }, scope = document.documentElement, includeSelf = false) {
|
||||
function unbindOutputs(scope = document.documentElement, includeSelf = false) {
|
||||
const outputs = (0, import_jquery35.default)(scope).find(".shiny-bound-output").toArray();
|
||||
if (includeSelf && (0, import_jquery35.default)(scope).hasClass("shiny-bound-output")) {
|
||||
outputs.push(scope);
|
||||
@@ -5825,6 +5874,20 @@ ${duplicateIdMsg}`;
|
||||
bindingsRegistry.removeBinding(id, "output");
|
||||
$el.removeClass("shiny-bound-output");
|
||||
$el.removeData("shiny-output-binding");
|
||||
for (const prefix of [
|
||||
"shiny-resize-observer",
|
||||
"shiny-intersection-observer",
|
||||
"shiny-mutate-observer"
|
||||
]) {
|
||||
const observer = $el.data(prefix);
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
$el.removeData(prefix);
|
||||
}
|
||||
const callback = $el.data(prefix + "-callback");
|
||||
callback?.cancel?.();
|
||||
$el.removeData(prefix + "-callback");
|
||||
}
|
||||
$el.trigger({
|
||||
type: "shiny:unbound",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
@@ -5832,8 +5895,7 @@ ${duplicateIdMsg}`;
|
||||
bindingType: "output"
|
||||
});
|
||||
}
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
}
|
||||
async function _bindAll(shinyCtx, scope) {
|
||||
await bindOutputs(shinyCtx, scope);
|
||||
@@ -5841,9 +5903,9 @@ ${duplicateIdMsg}`;
|
||||
bindingsRegistry.checkValidity(scope);
|
||||
return currentInputs;
|
||||
}
|
||||
function unbindAll(shinyCtx, scope, includeSelf = false) {
|
||||
function unbindAll(scope, includeSelf = false) {
|
||||
unbindInputs(scope, includeSelf);
|
||||
unbindOutputs(shinyCtx, scope, includeSelf);
|
||||
unbindOutputs(scope, includeSelf);
|
||||
}
|
||||
async function bindAll(shinyCtx, scope) {
|
||||
const currentInputItems = await _bindAll(shinyCtx, scope);
|
||||
@@ -6233,6 +6295,7 @@ ${duplicateIdMsg}`;
|
||||
var messageHandlers = {};
|
||||
var customMessageHandlerOrder = [];
|
||||
var customMessageHandlers = {};
|
||||
var conditionalShownClass = "shiny-conditional--shown";
|
||||
function addMessageHandler(type, handler) {
|
||||
if (messageHandlers[type]) {
|
||||
throw 'handler for message of type "' + type + '" already added.';
|
||||
@@ -6705,15 +6768,15 @@ ${duplicateIdMsg}`;
|
||||
const nsPrefix = el.attr("data-ns-prefix");
|
||||
const nsScope = this._narrowScope(scope, nsPrefix);
|
||||
const show3 = Boolean(condFunc(nsScope));
|
||||
const showing = el.css("display") !== "none";
|
||||
const showing = el.hasClass(conditionalShownClass);
|
||||
if (show3 !== showing) {
|
||||
if (show3) {
|
||||
el.trigger("show");
|
||||
el.show();
|
||||
el.addClass(conditionalShownClass);
|
||||
el.trigger("shown");
|
||||
} else {
|
||||
el.trigger("hide");
|
||||
el.hide();
|
||||
el.removeClass(conditionalShownClass);
|
||||
el.trigger("hidden");
|
||||
}
|
||||
}
|
||||
@@ -7288,8 +7351,6 @@ ${duplicateIdMsg}`;
|
||||
return {
|
||||
inputs,
|
||||
inputsRate,
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
inputBindings,
|
||||
outputBindings,
|
||||
initDeferredIframes,
|
||||
@@ -7300,7 +7361,7 @@ ${duplicateIdMsg}`;
|
||||
await bindAll(shinyBindCtx(), scope);
|
||||
};
|
||||
this.unbindAll = function(scope, includeSelf = false) {
|
||||
unbindAll(shinyBindCtx(), scope, includeSelf);
|
||||
unbindAll(scope, includeSelf);
|
||||
};
|
||||
function initializeInputs(scope = document.documentElement) {
|
||||
const bindings = inputBindings.getBindings();
|
||||
@@ -7322,230 +7383,178 @@ ${duplicateIdMsg}`;
|
||||
function getIdFromEl(el) {
|
||||
const $el = (0, import_jquery40.default)(el);
|
||||
const bindingAdapter = $el.data("shiny-output-binding");
|
||||
if (!bindingAdapter) return null;
|
||||
else return bindingAdapter.getId();
|
||||
return bindingAdapter ? bindingAdapter.getId() : null;
|
||||
}
|
||||
initializeInputs(document.documentElement);
|
||||
const initialValues = mapValues(
|
||||
await _bindAll(shinyBindCtx(), document.documentElement),
|
||||
(x2) => x2.value
|
||||
);
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function() {
|
||||
const id = getIdFromEl(this), rect = getBoundingClientSizeBeforeZoom(this);
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
initialValues[".clientdata_output_" + id + "_width"] = rect.width;
|
||||
initialValues[".clientdata_output_" + id + "_height"] = rect.height;
|
||||
}
|
||||
function setInput(name, value, initial = false) {
|
||||
if (initial) {
|
||||
initialValues[name] = value;
|
||||
} else {
|
||||
inputs.setInput(name, value);
|
||||
}
|
||||
);
|
||||
function getComputedBgColor(el) {
|
||||
if (!el) {
|
||||
return null;
|
||||
}
|
||||
const bgColor = getStyle(el, "background-color");
|
||||
if (!bgColor) return bgColor;
|
||||
const m2 = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/
|
||||
);
|
||||
if (bgColor === "transparent" || m2 && parseFloat(m2[4]) === 0) {
|
||||
const bgImage = getStyle(el, "background-image");
|
||||
if (bgImage && bgImage !== "none") {
|
||||
return null;
|
||||
} else {
|
||||
return getComputedBgColor(el.parentElement);
|
||||
}
|
||||
}
|
||||
return bgColor;
|
||||
}
|
||||
function getComputedFont(el) {
|
||||
const fontFamily = getStyle(el, "font-family");
|
||||
const fontSize = getStyle(el, "font-size");
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize
|
||||
};
|
||||
function doSendSize(el, initial = false) {
|
||||
const id = getIdFromEl(el);
|
||||
if (!id) return;
|
||||
const rect = getBoundingClientSizeBeforeZoom(el);
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
setInput(".clientdata_output_" + id + "_width", rect.width, initial);
|
||||
setInput(".clientdata_output_" + id + "_height", rect.height, initial);
|
||||
}
|
||||
}
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function() {
|
||||
const el = this;
|
||||
const id = getIdFromEl(el);
|
||||
initialValues[".clientdata_output_" + id + "_bg"] = getComputedBgColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_fg"] = getStyle(
|
||||
el,
|
||||
"color"
|
||||
);
|
||||
initialValues[".clientdata_output_" + id + "_accent"] = getComputedLinkColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_font"] = getComputedFont(el);
|
||||
maybeAddThemeObserver(el);
|
||||
}
|
||||
);
|
||||
function maybeAddThemeObserver(el) {
|
||||
if (!window.MutationObserver) {
|
||||
return;
|
||||
}
|
||||
const cl = el.classList;
|
||||
const reportTheme = cl.contains("shiny-image-output") || cl.contains("shiny-plot-output") || cl.contains("shiny-report-theme");
|
||||
if (!reportTheme) {
|
||||
return;
|
||||
}
|
||||
const $el = (0, import_jquery40.default)(el);
|
||||
if ($el.data("shiny-theme-observer")) {
|
||||
return;
|
||||
}
|
||||
const observerCallback = new Debouncer(null, () => doSendTheme(el), 100);
|
||||
const observer = new MutationObserver(
|
||||
() => observerCallback.normalCall()
|
||||
);
|
||||
const config = { attributes: true, attributeFilter: ["style", "class"] };
|
||||
observer.observe(el, config);
|
||||
$el.data("shiny-theme-observer", observer);
|
||||
function doTriggerResize(el) {
|
||||
const $el = (0, import_jquery40.default)(el), binding = $el.data("shiny-output-binding");
|
||||
if (!binding) return;
|
||||
$el.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: isVisible(el),
|
||||
binding
|
||||
});
|
||||
binding.onResize();
|
||||
}
|
||||
function doSendTheme(el) {
|
||||
function doSendTheme(el, initial = false) {
|
||||
if (el.classList.contains("shiny-output-error")) {
|
||||
return;
|
||||
}
|
||||
const id = getIdFromEl(el);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el)
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color")
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el)
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el)
|
||||
);
|
||||
}
|
||||
function doSendImageSize() {
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function() {
|
||||
const id = getIdFromEl(this), rect = getBoundingClientSizeBeforeZoom(this);
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_width", rect.width);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_height",
|
||||
rect.height
|
||||
);
|
||||
function getComputedBgColor(el2) {
|
||||
if (!el2) {
|
||||
return null;
|
||||
}
|
||||
const bgColor = getStyle(el2, "background-color");
|
||||
if (!bgColor) return bgColor;
|
||||
const m2 = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/
|
||||
);
|
||||
if (bgColor === "transparent" || m2 && parseFloat(m2[4]) === 0) {
|
||||
const bgImage = getStyle(el2, "background-image");
|
||||
if (bgImage && bgImage !== "none") {
|
||||
return null;
|
||||
} else {
|
||||
return getComputedBgColor(el2.parentElement);
|
||||
}
|
||||
}
|
||||
);
|
||||
(0, import_jquery40.default)(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function() {
|
||||
doSendTheme(this);
|
||||
}
|
||||
);
|
||||
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
|
||||
const $this = (0, import_jquery40.default)(this), binding = $this.data("shiny-output-binding");
|
||||
$this.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: !isHidden(this),
|
||||
binding
|
||||
});
|
||||
binding.onResize();
|
||||
});
|
||||
}
|
||||
sendImageSizeFns.setImageSend(inputBatchSender, doSendImageSize);
|
||||
function isHidden(obj) {
|
||||
if (obj === null || obj.offsetWidth !== 0 || obj.offsetHeight !== 0) {
|
||||
return false;
|
||||
} else if (getStyle(obj, "display") === "none") {
|
||||
return true;
|
||||
} else {
|
||||
return isHidden(obj.parentNode);
|
||||
return bgColor;
|
||||
}
|
||||
}
|
||||
let lastKnownVisibleOutputs = {};
|
||||
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
|
||||
const id = getIdFromEl(this);
|
||||
if (isHidden(this)) {
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = true;
|
||||
} else {
|
||||
lastKnownVisibleOutputs[id] = true;
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = false;
|
||||
}
|
||||
});
|
||||
function doSendOutputHiddenState() {
|
||||
const visibleOutputs = {};
|
||||
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
|
||||
const id = getIdFromEl(this);
|
||||
delete lastKnownVisibleOutputs[id];
|
||||
const hidden = isHidden(this), evt = {
|
||||
type: "shiny:visualchange",
|
||||
visible: !hidden
|
||||
function getComputedFont(el2) {
|
||||
const fontFamily = getStyle(el2, "font-family");
|
||||
const fontSize = getStyle(el2, "font-size");
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize
|
||||
};
|
||||
if (hidden) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", true);
|
||||
} else {
|
||||
visibleOutputs[id] = true;
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", false);
|
||||
}
|
||||
const $this = (0, import_jquery40.default)(this);
|
||||
evt.binding = $this.data("shiny-output-binding");
|
||||
$this.trigger(evt);
|
||||
});
|
||||
for (const name in lastKnownVisibleOutputs) {
|
||||
if (hasDefinedProperty(lastKnownVisibleOutputs, name))
|
||||
inputs.setInput(".clientdata_output_" + name + "_hidden", true);
|
||||
}
|
||||
lastKnownVisibleOutputs = visibleOutputs;
|
||||
const id = getIdFromEl(el);
|
||||
if (!id) return;
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el),
|
||||
initial
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color"),
|
||||
initial
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el),
|
||||
initial
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el),
|
||||
initial
|
||||
);
|
||||
}
|
||||
const sendOutputHiddenStateDebouncer = new Debouncer(
|
||||
null,
|
||||
doSendOutputHiddenState,
|
||||
0
|
||||
);
|
||||
function sendOutputHiddenState() {
|
||||
sendOutputHiddenStateDebouncer.normalCall();
|
||||
const visibleOutputs = /* @__PURE__ */ new Set();
|
||||
function doSendHiddenState(el, initial = false) {
|
||||
const id = getIdFromEl(el);
|
||||
if (!id) return;
|
||||
const hidden = !isVisible(el);
|
||||
if (hidden) {
|
||||
visibleOutputs.delete(id);
|
||||
} else {
|
||||
visibleOutputs.add(id);
|
||||
}
|
||||
setInput(".clientdata_output_" + id + "_hidden", hidden, initial);
|
||||
}
|
||||
inputBatchSender.lastChanceCallback.push(function() {
|
||||
if (sendOutputHiddenStateDebouncer.isPending())
|
||||
sendOutputHiddenStateDebouncer.immediateCall();
|
||||
});
|
||||
function filterEventsByNamespace(namespace, handler, ...args) {
|
||||
const namespaceArr = namespace.split(".");
|
||||
return function(e4) {
|
||||
const eventNamespace = e4.namespace?.split(".") ?? [];
|
||||
for (let i5 = 0; i5 < namespaceArr.length; i5++) {
|
||||
if (eventNamespace.indexOf(namespaceArr[i5]) === -1) return;
|
||||
function reportsSize(el) {
|
||||
return el.classList.contains("shiny-image-output") || el.classList.contains("shiny-plot-output") || el.classList.contains("shiny-report-size");
|
||||
}
|
||||
function reportsTheme(el) {
|
||||
return el.classList.contains("shiny-image-output") || el.classList.contains("shiny-plot-output") || el.classList.contains("shiny-report-theme");
|
||||
}
|
||||
function handleVisualChange(el) {
|
||||
doTriggerResize(el);
|
||||
doSendHiddenState(el);
|
||||
if (reportsSize(el)) doSendSize(el);
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
}
|
||||
function ensureObservers(el) {
|
||||
const $el = (0, import_jquery40.default)(el);
|
||||
if (!$el.data("shiny-resize-observer")) {
|
||||
const onResize = sendOutputInfoFns.createObserverCallback(
|
||||
100,
|
||||
() => handleVisualChange(el)
|
||||
);
|
||||
const ro = new ResizeObserver(() => onResize());
|
||||
ro.observe(el);
|
||||
$el.data("shiny-resize-observer-callback", onResize);
|
||||
$el.data("shiny-resize-observer", ro);
|
||||
}
|
||||
if (!$el.data("shiny-intersection-observer")) {
|
||||
const onIntersect = sendOutputInfoFns.createObserverCallback(
|
||||
100,
|
||||
() => handleVisualChange(el)
|
||||
);
|
||||
const io = new IntersectionObserver(() => onIntersect());
|
||||
io.observe(el);
|
||||
$el.data("shiny-intersection-observer-callback", onIntersect);
|
||||
$el.data("shiny-intersection-observer", io);
|
||||
}
|
||||
if (reportsTheme(el) && !$el.data("shiny-mutate-observer")) {
|
||||
const onMutate = sendOutputInfoFns.createObserverCallback(100, () => {
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
});
|
||||
const mo = new MutationObserver(() => onMutate());
|
||||
mo.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"]
|
||||
});
|
||||
$el.data("shiny-mutate-observer", mo);
|
||||
$el.data("shiny-mutate-observer-callback", onMutate);
|
||||
}
|
||||
}
|
||||
function doSendOutputInfo(initial = false) {
|
||||
const outputIds = /* @__PURE__ */ new Set();
|
||||
(0, import_jquery40.default)(".shiny-bound-output").each(function() {
|
||||
const el = this;
|
||||
const id = getIdFromEl(el);
|
||||
if (id) outputIds.add(id);
|
||||
ensureObservers(el);
|
||||
if (!initial) doTriggerResize(el);
|
||||
doSendHiddenState(el, initial);
|
||||
if (reportsSize(el)) {
|
||||
doSendSize(el, initial);
|
||||
}
|
||||
handler.apply(this, [namespaceArr, handler, ...args]);
|
||||
};
|
||||
if (reportsTheme(el)) {
|
||||
doSendTheme(el, initial);
|
||||
}
|
||||
});
|
||||
visibleOutputs.forEach((id) => {
|
||||
if (!outputIds.has(id)) {
|
||||
visibleOutputs.delete(id);
|
||||
setInput(".clientdata_output_" + id + "_hidden", true, initial);
|
||||
}
|
||||
});
|
||||
}
|
||||
(0, import_jquery40.default)(window).resize(debounce(500, sendImageSizeFns.regular));
|
||||
const bs3classes = [
|
||||
"modal",
|
||||
"dropdown",
|
||||
"tab",
|
||||
"tooltip",
|
||||
"popover",
|
||||
"collapse"
|
||||
];
|
||||
import_jquery40.default.each(bs3classes, function(idx, classname) {
|
||||
(0, import_jquery40.default)(document.body).on(
|
||||
"shown.bs." + classname + ".sendImageSize",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendImageSizeFns.regular)
|
||||
);
|
||||
(0, import_jquery40.default)(document.body).on(
|
||||
"shown.bs." + classname + ".sendOutputHiddenState hidden.bs." + classname + ".sendOutputHiddenState",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendOutputHiddenState)
|
||||
);
|
||||
});
|
||||
(0, import_jquery40.default)(document.body).on("shown.sendImageSize", "*", sendImageSizeFns.regular);
|
||||
(0, import_jquery40.default)(document.body).on(
|
||||
"shown.sendOutputHiddenState hidden.sendOutputHiddenState",
|
||||
"*",
|
||||
sendOutputHiddenState
|
||||
);
|
||||
doSendOutputInfo(true);
|
||||
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
|
||||
initialValues[".clientdata_pixelratio"] = pixelRatio();
|
||||
(0, import_jquery40.default)(window).resize(function() {
|
||||
inputs.setInput(".clientdata_pixelratio", pixelRatio());
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
inst/www/shared/shiny.min.css
vendored
2
inst/www/shared/shiny.min.css
vendored
File diff suppressed because one or more lines are too long
40
inst/www/shared/shiny.min.js
vendored
40
inst/www/shared/shiny.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,7 +39,7 @@ $datepicker-disabled-color: $dropdown-link-disabled-color !default;
|
||||
$shiny-file-active-shadow: $input-focus-box-shadow !default;
|
||||
|
||||
|
||||
.shiny-panel-conditional,
|
||||
[data-display-if].shiny-conditional--shown,
|
||||
div:where(.shiny-html-output) {
|
||||
/* uiOutput()/ conditionalPanel() are "pass-through" containers when they have children. */
|
||||
&:has(> *) {
|
||||
|
||||
@@ -488,6 +488,11 @@ textarea.textarea-autoresize.form-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* conditionalPanel: hidden until JS evaluates the condition */
|
||||
[data-display-if]:not(.shiny-conditional--shown) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hidden tabPanels */
|
||||
.nav-hidden {
|
||||
/* override anything bootstrap sets for `.nav` */
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@posit/shiny",
|
||||
"version": "1.12.1-alpha.9000",
|
||||
"version": "1.13.0-alpha.9000",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@posit/shiny",
|
||||
"version": "1.12.1-alpha.9000",
|
||||
"version": "1.13.0-alpha.9000",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/bootstrap": "5.2.x",
|
||||
|
||||
@@ -63,9 +63,10 @@
|
||||
"bundle_shiny": "tsx srcts/build/shiny.ts",
|
||||
"bundle_external_libs": "tsx srcts/build/external_libs.ts",
|
||||
"bundle_extras": "tsx srcts/build/extras.ts",
|
||||
"checks": "npm run lint && npm run build_types && npm run coverage && npm run circular",
|
||||
"checks": "npm run lint && npm run build_types && npm run test_types && npm run coverage && npm run circular",
|
||||
"lint": "node --eval \"console.log('linting code...')\" && eslint 'srcts/src/**/*.ts' --fix",
|
||||
"build_types": "tsc -p tsconfig.json",
|
||||
"test_types": "tsx --test $(find srcts/src -path '*/__tests__/*.test.ts' -print)",
|
||||
"coverage_detailed": "npx --yes type-check --detail",
|
||||
"coverage": "type-coverage -p tsconfig.json --at-least 90",
|
||||
"circular": "npx --yes dpdm --transform ./srcts/src/index.ts",
|
||||
|
||||
@@ -39,17 +39,5 @@ declare global {
|
||||
): this;
|
||||
on(events: EvtPrefix<"mouseup">, handler: EvtFn<JQuery.MouseUpEvent>): this;
|
||||
on(events: EvtPrefix<"resize">, handler: EvtFn<JQuery.ResizeEvent>): this;
|
||||
|
||||
on(
|
||||
events: `shown.bs.${string}.sendImageSize`,
|
||||
selector: string,
|
||||
handler: (
|
||||
this: HTMLElement,
|
||||
e: JQuery.EventHandlerBase<HTMLElement, any>,
|
||||
// e: JQuery.Event & {
|
||||
// namespace: string;
|
||||
// }
|
||||
) => void,
|
||||
): this;
|
||||
}
|
||||
}
|
||||
|
||||
42
srcts/src/shiny/__tests__/sendOutputInfo.test.ts
Normal file
42
srcts/src/shiny/__tests__/sendOutputInfo.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { InputBatchSender } from "../../inputPolicies";
|
||||
import { SendOutputInfo } from "../sendOutputInfo";
|
||||
|
||||
void test("pending observer output info is flushed before the next input batch send", () => {
|
||||
const sentInputs: Array<{ [key: string]: unknown }> = [];
|
||||
const shinyapp = {
|
||||
taskQueue: {
|
||||
enqueue: () => {
|
||||
throw new Error("task queue should not be used in this test");
|
||||
},
|
||||
},
|
||||
sendInput: (values: { [key: string]: unknown }) => {
|
||||
sentInputs.push(values);
|
||||
},
|
||||
};
|
||||
const inputBatchSender = new InputBatchSender(shinyapp as never);
|
||||
const sendOutputInfo = new SendOutputInfo();
|
||||
|
||||
sendOutputInfo.setSendMethod(inputBatchSender, () => {
|
||||
/* no-op */
|
||||
});
|
||||
|
||||
const observerCallback = sendOutputInfo.createObserverCallback(100, () => {
|
||||
inputBatchSender.setInput(".clientdata_output_plot_width", 400, {
|
||||
priority: "immediate",
|
||||
});
|
||||
});
|
||||
|
||||
observerCallback();
|
||||
|
||||
inputBatchSender.setInput("user", 1, { priority: "event" });
|
||||
|
||||
assert.equal(sentInputs.length, 1);
|
||||
const expected: { [key: string]: unknown } = { user: 1 };
|
||||
|
||||
expected[".clientdata_output_plot_width"] = 400;
|
||||
|
||||
assert.deepEqual(sentInputs[0], expected);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
} from "../inputPolicies";
|
||||
import type { EventPriority } from "../inputPolicies/inputPolicy";
|
||||
import { shinyAppBindOutput, shinyAppUnbindOutput } from "./initedMethods";
|
||||
import { sendImageSizeFns } from "./sendImageSize";
|
||||
import { sendOutputInfoFns } from "./sendOutputInfo";
|
||||
|
||||
type BindScope = HTMLElement | JQuery<HTMLElement>;
|
||||
|
||||
@@ -229,8 +229,6 @@ type BindInputsCtx = {
|
||||
inputsRate: InputRateDecorator;
|
||||
inputBindings: BindingRegistry<InputBinding>;
|
||||
outputBindings: BindingRegistry<OutputBinding>;
|
||||
sendOutputHiddenState: () => void;
|
||||
maybeAddThemeObserver: (el: HTMLElement) => void;
|
||||
initDeferredIframes: () => void;
|
||||
outputIsRecalculating: (id: string) => boolean;
|
||||
};
|
||||
@@ -318,12 +316,7 @@ function bindInputs(
|
||||
}
|
||||
|
||||
async function bindOutputs(
|
||||
{
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
outputBindings,
|
||||
outputIsRecalculating,
|
||||
}: BindInputsCtx,
|
||||
{ outputBindings, outputIsRecalculating }: BindInputsCtx,
|
||||
scope: BindScope = document.documentElement,
|
||||
): Promise<void> {
|
||||
const $scope = $(scope);
|
||||
@@ -355,12 +348,6 @@ async function bindOutputs(
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this element reports its CSS styles to getCurrentOutputInfo()
|
||||
// then it should have a MutationObserver() to resend CSS if its
|
||||
// style/class attributes change. This observer should already exist
|
||||
// for _static_ UI, but not yet for _dynamic_ UI
|
||||
maybeAddThemeObserver(el);
|
||||
|
||||
const bindingAdapter = new OutputBindingAdapter(el, binding);
|
||||
|
||||
await shinyAppBindOutput(id, bindingAdapter);
|
||||
@@ -383,8 +370,7 @@ async function bindOutputs(
|
||||
}
|
||||
|
||||
// Send later in case DOM layout isn't final yet.
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
}
|
||||
|
||||
function unbindInputs(
|
||||
@@ -419,7 +405,6 @@ function unbindInputs(
|
||||
}
|
||||
}
|
||||
function unbindOutputs(
|
||||
{ sendOutputHiddenState }: BindInputsCtx,
|
||||
scope: BindScope = document.documentElement,
|
||||
includeSelf = false,
|
||||
) {
|
||||
@@ -443,6 +428,27 @@ function unbindOutputs(
|
||||
bindingsRegistry.removeBinding(id, "output");
|
||||
$el.removeClass("shiny-bound-output");
|
||||
$el.removeData("shiny-output-binding");
|
||||
|
||||
for (const prefix of [
|
||||
"shiny-resize-observer",
|
||||
"shiny-intersection-observer",
|
||||
"shiny-mutate-observer",
|
||||
]) {
|
||||
const observer = $el.data(prefix);
|
||||
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
$el.removeData(prefix);
|
||||
}
|
||||
|
||||
const callback = $el.data(prefix + "-callback") as
|
||||
| { cancel?: () => void }
|
||||
| undefined;
|
||||
|
||||
callback?.cancel?.();
|
||||
$el.removeData(prefix + "-callback");
|
||||
}
|
||||
|
||||
$el.trigger({
|
||||
type: "shiny:unbound",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
@@ -452,8 +458,7 @@ function unbindOutputs(
|
||||
}
|
||||
|
||||
// Send later in case DOM layout isn't final yet.
|
||||
setTimeout(sendImageSizeFns.regular, 0);
|
||||
setTimeout(sendOutputHiddenState, 0);
|
||||
setTimeout(() => sendOutputInfoFns.regular(), 0);
|
||||
}
|
||||
|
||||
// (Named used before TS conversion)
|
||||
@@ -474,13 +479,9 @@ async function _bindAll(
|
||||
|
||||
return currentInputs;
|
||||
}
|
||||
function unbindAll(
|
||||
shinyCtx: BindInputsCtx,
|
||||
scope: BindScope,
|
||||
includeSelf = false,
|
||||
): void {
|
||||
function unbindAll(scope: BindScope, includeSelf = false): void {
|
||||
unbindInputs(scope, includeSelf);
|
||||
unbindOutputs(shinyCtx, scope, includeSelf);
|
||||
unbindOutputs(scope, includeSelf);
|
||||
}
|
||||
async function bindAll(
|
||||
shinyCtx: BindInputsCtx,
|
||||
|
||||
@@ -17,15 +17,14 @@ import {
|
||||
} from "../inputPolicies";
|
||||
import type { InputPolicyOpts } from "../inputPolicies/inputPolicy";
|
||||
import { addDefaultInputOpts } from "../inputPolicies/inputValidateDecorator";
|
||||
import { debounce, Debouncer } from "../time";
|
||||
import {
|
||||
$escape,
|
||||
compareVersion,
|
||||
getBoundingClientSizeBeforeZoom,
|
||||
getComputedLinkColor,
|
||||
getStyle,
|
||||
hasDefinedProperty,
|
||||
isShinyInDevMode,
|
||||
isVisible,
|
||||
mapValues,
|
||||
pixelRatio,
|
||||
} from "../utils";
|
||||
@@ -52,7 +51,7 @@ import {
|
||||
renderHtml,
|
||||
renderHtmlAsync,
|
||||
} from "./render";
|
||||
import { sendImageSizeFns } from "./sendImageSize";
|
||||
import { sendOutputInfoFns } from "./sendOutputInfo";
|
||||
import { addCustomMessageHandler, ShinyApp, type Handler } from "./shinyapp";
|
||||
import { registerNames as singletonsRegisterNames } from "./singletons";
|
||||
|
||||
@@ -220,8 +219,6 @@ class ShinyClass {
|
||||
return {
|
||||
inputs,
|
||||
inputsRate,
|
||||
sendOutputHiddenState,
|
||||
maybeAddThemeObserver,
|
||||
inputBindings,
|
||||
outputBindings,
|
||||
initDeferredIframes,
|
||||
@@ -234,7 +231,7 @@ class ShinyClass {
|
||||
await bindAll(shinyBindCtx(), scope);
|
||||
};
|
||||
this.unbindAll = function (scope: BindScope, includeSelf = false) {
|
||||
unbindAll(shinyBindCtx(), scope, includeSelf);
|
||||
unbindAll(scope, includeSelf);
|
||||
};
|
||||
|
||||
// Calls .initialize() for all of the input objects in all input bindings,
|
||||
@@ -262,12 +259,11 @@ class ShinyClass {
|
||||
}
|
||||
this.initializeInputs = initializeInputs;
|
||||
|
||||
function getIdFromEl(el: HTMLElement) {
|
||||
function getIdFromEl(el: HTMLElement): string | null {
|
||||
const $el = $(el);
|
||||
const bindingAdapter = $el.data("shiny-output-binding");
|
||||
|
||||
if (!bindingAdapter) return null;
|
||||
else return bindingAdapter.getId();
|
||||
return bindingAdapter ? bindingAdapter.getId() : null;
|
||||
}
|
||||
|
||||
// Initialize all input objects in the document, before binding
|
||||
@@ -285,327 +281,224 @@ class ShinyClass {
|
||||
(x) => x.value,
|
||||
);
|
||||
|
||||
// The server needs to know the size of each image and plot output element,
|
||||
// in case it is auto-sizing
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function () {
|
||||
const id = getIdFromEl(this),
|
||||
rect = getBoundingClientSizeBeforeZoom(this);
|
||||
function setInput(name: string, value: unknown, initial = false): void {
|
||||
if (initial) {
|
||||
initialValues[name] = value;
|
||||
} else {
|
||||
inputs.setInput(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
initialValues[".clientdata_output_" + id + "_width"] = rect.width;
|
||||
initialValues[".clientdata_output_" + id + "_height"] = rect.height;
|
||||
}
|
||||
},
|
||||
);
|
||||
function doSendSize(el: HTMLElement, initial = false): void {
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
function getComputedBgColor(
|
||||
el: HTMLElement | null,
|
||||
): string | null | undefined {
|
||||
if (!el) {
|
||||
// Top of document, can't recurse further
|
||||
return null;
|
||||
if (!id) return;
|
||||
|
||||
const rect = getBoundingClientSizeBeforeZoom(el);
|
||||
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
setInput(".clientdata_output_" + id + "_width", rect.width, initial);
|
||||
setInput(".clientdata_output_" + id + "_height", rect.height, initial);
|
||||
}
|
||||
}
|
||||
|
||||
function doTriggerResize(el: HTMLElement): void {
|
||||
const $el = $(el),
|
||||
binding = $el.data("shiny-output-binding");
|
||||
|
||||
if (!binding) return;
|
||||
|
||||
$el.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: isVisible(el),
|
||||
binding: binding,
|
||||
});
|
||||
binding.onResize();
|
||||
}
|
||||
|
||||
function doSendTheme(el: HTMLElement, initial = false): void {
|
||||
if (el.classList.contains("shiny-output-error")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bgColor = getStyle(el, "background-color");
|
||||
|
||||
if (!bgColor) return bgColor;
|
||||
const m = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
|
||||
);
|
||||
|
||||
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
|
||||
// No background color on this element. See if it has a background image.
|
||||
const bgImage = getStyle(el, "background-image");
|
||||
|
||||
if (bgImage && bgImage !== "none") {
|
||||
// Failed to detect background color, since it has a background image
|
||||
function getComputedBgColor(
|
||||
el: HTMLElement | null,
|
||||
): string | null | undefined {
|
||||
if (!el) {
|
||||
return null;
|
||||
} else {
|
||||
// Recurse
|
||||
return getComputedBgColor(el.parentElement);
|
||||
}
|
||||
|
||||
const bgColor = getStyle(el, "background-color");
|
||||
|
||||
if (!bgColor) return bgColor;
|
||||
const m = bgColor.match(
|
||||
/^rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/,
|
||||
);
|
||||
|
||||
if (bgColor === "transparent" || (m && parseFloat(m[4]) === 0)) {
|
||||
const bgImage = getStyle(el, "background-image");
|
||||
|
||||
if (bgImage && bgImage !== "none") {
|
||||
return null;
|
||||
} else {
|
||||
return getComputedBgColor(el.parentElement);
|
||||
}
|
||||
}
|
||||
return bgColor;
|
||||
}
|
||||
return bgColor;
|
||||
|
||||
function getComputedFont(el: HTMLElement): {
|
||||
families: string[] | undefined;
|
||||
size: string | undefined;
|
||||
} {
|
||||
const fontFamily = getStyle(el, "font-family");
|
||||
const fontSize = getStyle(el, "font-size");
|
||||
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize,
|
||||
};
|
||||
}
|
||||
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el),
|
||||
initial,
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color"),
|
||||
initial,
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el),
|
||||
initial,
|
||||
);
|
||||
setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el),
|
||||
initial,
|
||||
);
|
||||
}
|
||||
|
||||
function getComputedFont(el: HTMLElement) {
|
||||
const fontFamily = getStyle(el, "font-family");
|
||||
const fontSize = getStyle(el, "font-size");
|
||||
const visibleOutputs = new Set<string>();
|
||||
|
||||
return {
|
||||
families: fontFamily?.replace(/"/g, "").split(", "),
|
||||
size: fontSize,
|
||||
};
|
||||
function doSendHiddenState(el: HTMLElement, initial = false): void {
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
if (!id) return;
|
||||
|
||||
const hidden = !isVisible(el);
|
||||
|
||||
if (hidden) {
|
||||
visibleOutputs.delete(id);
|
||||
} else {
|
||||
visibleOutputs.add(id);
|
||||
}
|
||||
setInput(".clientdata_output_" + id + "_hidden", hidden, initial);
|
||||
}
|
||||
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function () {
|
||||
function reportsSize(el: HTMLElement): boolean {
|
||||
return (
|
||||
el.classList.contains("shiny-image-output") ||
|
||||
el.classList.contains("shiny-plot-output") ||
|
||||
el.classList.contains("shiny-report-size")
|
||||
);
|
||||
}
|
||||
|
||||
function reportsTheme(el: HTMLElement): boolean {
|
||||
return (
|
||||
el.classList.contains("shiny-image-output") ||
|
||||
el.classList.contains("shiny-plot-output") ||
|
||||
el.classList.contains("shiny-report-theme")
|
||||
);
|
||||
}
|
||||
|
||||
function handleVisualChange(el: HTMLElement): void {
|
||||
doTriggerResize(el);
|
||||
doSendHiddenState(el);
|
||||
if (reportsSize(el)) doSendSize(el);
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
}
|
||||
|
||||
function ensureObservers(el: HTMLElement): void {
|
||||
const $el = $(el);
|
||||
|
||||
if (!$el.data("shiny-resize-observer")) {
|
||||
const onResize = sendOutputInfoFns.createObserverCallback(100, () =>
|
||||
handleVisualChange(el),
|
||||
);
|
||||
const ro = new ResizeObserver(() => onResize());
|
||||
|
||||
ro.observe(el);
|
||||
$el.data("shiny-resize-observer-callback", onResize);
|
||||
$el.data("shiny-resize-observer", ro);
|
||||
}
|
||||
|
||||
if (!$el.data("shiny-intersection-observer")) {
|
||||
const onIntersect = sendOutputInfoFns.createObserverCallback(100, () =>
|
||||
handleVisualChange(el),
|
||||
);
|
||||
const io = new IntersectionObserver(() => onIntersect());
|
||||
|
||||
io.observe(el);
|
||||
$el.data("shiny-intersection-observer-callback", onIntersect);
|
||||
$el.data("shiny-intersection-observer", io);
|
||||
}
|
||||
|
||||
if (reportsTheme(el) && !$el.data("shiny-mutate-observer")) {
|
||||
const onMutate = sendOutputInfoFns.createObserverCallback(100, () => {
|
||||
if (reportsTheme(el)) doSendTheme(el);
|
||||
});
|
||||
const mo = new MutationObserver(() => onMutate());
|
||||
|
||||
mo.observe(el, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"],
|
||||
});
|
||||
|
||||
$el.data("shiny-mutate-observer", mo);
|
||||
$el.data("shiny-mutate-observer-callback", onMutate);
|
||||
}
|
||||
}
|
||||
|
||||
function doSendOutputInfo(initial = false) {
|
||||
const outputIds = new Set<string>();
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const el = this;
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
initialValues[".clientdata_output_" + id + "_bg"] =
|
||||
getComputedBgColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_fg"] = getStyle(
|
||||
el,
|
||||
"color",
|
||||
);
|
||||
initialValues[".clientdata_output_" + id + "_accent"] =
|
||||
getComputedLinkColor(el);
|
||||
initialValues[".clientdata_output_" + id + "_font"] =
|
||||
getComputedFont(el);
|
||||
maybeAddThemeObserver(el);
|
||||
},
|
||||
);
|
||||
if (id) outputIds.add(id);
|
||||
ensureObservers(el);
|
||||
|
||||
// Resend computed styles if *an output element's* class or style attribute changes.
|
||||
// This gives us some level of confidence that getCurrentOutputInfo() will be
|
||||
// properly invalidated if output container is mutated; but unfortunately,
|
||||
// we don't have a reasonable way to detect change in *inherited* styles
|
||||
// (other than session$setCurrentTheme())
|
||||
// https://github.com/rstudio/shiny/issues/3196
|
||||
// https://github.com/rstudio/shiny/issues/2998
|
||||
function maybeAddThemeObserver(el: HTMLElement): void {
|
||||
if (!window.MutationObserver) {
|
||||
return; // IE10 and lower
|
||||
}
|
||||
if (!initial) doTriggerResize(el);
|
||||
doSendHiddenState(el, initial);
|
||||
if (reportsSize(el)) {
|
||||
doSendSize(el, initial);
|
||||
}
|
||||
if (reportsTheme(el)) {
|
||||
doSendTheme(el, initial);
|
||||
}
|
||||
});
|
||||
|
||||
const cl = el.classList;
|
||||
const reportTheme =
|
||||
cl.contains("shiny-image-output") ||
|
||||
cl.contains("shiny-plot-output") ||
|
||||
cl.contains("shiny-report-theme");
|
||||
|
||||
if (!reportTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $el = $(el);
|
||||
|
||||
if ($el.data("shiny-theme-observer")) {
|
||||
return; // i.e., observer is already observing
|
||||
}
|
||||
|
||||
const observerCallback = new Debouncer(null, () => doSendTheme(el), 100);
|
||||
const observer = new MutationObserver(() =>
|
||||
observerCallback.normalCall(),
|
||||
);
|
||||
const config = { attributes: true, attributeFilter: ["style", "class"] };
|
||||
|
||||
observer.observe(el, config);
|
||||
$el.data("shiny-theme-observer", observer);
|
||||
}
|
||||
|
||||
function doSendTheme(el: HTMLElement): void {
|
||||
// Sending theme info on error isn't necessary (it'd add an unnecessary additional round-trip)
|
||||
if (el.classList.contains("shiny-output-error")) {
|
||||
return;
|
||||
}
|
||||
const id = getIdFromEl(el);
|
||||
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_bg",
|
||||
getComputedBgColor(el),
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_fg",
|
||||
getStyle(el, "color"),
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_accent",
|
||||
getComputedLinkColor(el),
|
||||
);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_font",
|
||||
getComputedFont(el),
|
||||
);
|
||||
}
|
||||
|
||||
function doSendImageSize() {
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-size").each(
|
||||
function () {
|
||||
const id = getIdFromEl(this),
|
||||
rect = getBoundingClientSizeBeforeZoom(this);
|
||||
|
||||
if (rect.width !== 0 || rect.height !== 0) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_width", rect.width);
|
||||
inputs.setInput(
|
||||
".clientdata_output_" + id + "_height",
|
||||
rect.height,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$(".shiny-image-output, .shiny-plot-output, .shiny-report-theme").each(
|
||||
function () {
|
||||
doSendTheme(this);
|
||||
},
|
||||
);
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
const $this = $(this),
|
||||
binding = $this.data("shiny-output-binding");
|
||||
|
||||
$this.trigger({
|
||||
type: "shiny:visualchange",
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
visible: !isHidden(this),
|
||||
binding: binding,
|
||||
});
|
||||
binding.onResize();
|
||||
visibleOutputs.forEach((id) => {
|
||||
if (!outputIds.has(id)) {
|
||||
visibleOutputs.delete(id);
|
||||
setInput(".clientdata_output_" + id + "_hidden", true, initial);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendImageSizeFns.setImageSend(inputBatchSender, doSendImageSize);
|
||||
|
||||
// Return true if the object or one of its ancestors in the DOM tree has
|
||||
// style='display:none'; otherwise return false.
|
||||
function isHidden(obj: HTMLElement | null): boolean {
|
||||
// null means we've hit the top of the tree. If width or height is
|
||||
// non-zero, then we know that no ancestor has display:none.
|
||||
if (obj === null || obj.offsetWidth !== 0 || obj.offsetHeight !== 0) {
|
||||
return false;
|
||||
} else if (getStyle(obj, "display") === "none") {
|
||||
return true;
|
||||
} else {
|
||||
return isHidden(obj.parentNode as HTMLElement | null);
|
||||
}
|
||||
}
|
||||
let lastKnownVisibleOutputs: { [key: string]: boolean } = {};
|
||||
// Set initial state of outputs to hidden, if needed
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
const id = getIdFromEl(this);
|
||||
|
||||
if (isHidden(this)) {
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = true;
|
||||
} else {
|
||||
lastKnownVisibleOutputs[id] = true;
|
||||
initialValues[".clientdata_output_" + id + "_hidden"] = false;
|
||||
}
|
||||
});
|
||||
// Send update when hidden state changes
|
||||
function doSendOutputHiddenState() {
|
||||
const visibleOutputs: { [key: string]: boolean } = {};
|
||||
|
||||
$(".shiny-bound-output").each(function () {
|
||||
const id = getIdFromEl(this);
|
||||
|
||||
delete lastKnownVisibleOutputs[id];
|
||||
// Assume that the object is hidden when width and height are 0
|
||||
const hidden = isHidden(this),
|
||||
evt = {
|
||||
type: "shiny:visualchange",
|
||||
visible: !hidden,
|
||||
};
|
||||
|
||||
if (hidden) {
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", true);
|
||||
} else {
|
||||
visibleOutputs[id] = true;
|
||||
inputs.setInput(".clientdata_output_" + id + "_hidden", false);
|
||||
}
|
||||
const $this = $(this);
|
||||
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
evt.binding = $this.data("shiny-output-binding");
|
||||
// @ts-expect-error; Can not remove info on a established, malformed Event object
|
||||
$this.trigger(evt);
|
||||
});
|
||||
// Anything left in lastKnownVisibleOutputs is orphaned
|
||||
for (const name in lastKnownVisibleOutputs) {
|
||||
if (hasDefinedProperty(lastKnownVisibleOutputs, name))
|
||||
inputs.setInput(".clientdata_output_" + name + "_hidden", true);
|
||||
}
|
||||
// Update the visible outputs for next time
|
||||
lastKnownVisibleOutputs = visibleOutputs;
|
||||
}
|
||||
// sendOutputHiddenState gets called each time DOM elements are shown or
|
||||
// hidden. This can be in the hundreds or thousands of times at startup.
|
||||
// We'll debounce it, so that we do the actual work once per tick.
|
||||
const sendOutputHiddenStateDebouncer = new Debouncer(
|
||||
null,
|
||||
doSendOutputHiddenState,
|
||||
0,
|
||||
);
|
||||
|
||||
function sendOutputHiddenState() {
|
||||
sendOutputHiddenStateDebouncer.normalCall();
|
||||
}
|
||||
// We need to make sure doSendOutputHiddenState actually gets called before
|
||||
// the inputBatchSender sends data to the server. The lastChanceCallback
|
||||
// here does that - if the debouncer has a pending call, flush it.
|
||||
inputBatchSender.lastChanceCallback.push(function () {
|
||||
if (sendOutputHiddenStateDebouncer.isPending())
|
||||
sendOutputHiddenStateDebouncer.immediateCall();
|
||||
});
|
||||
|
||||
// Given a namespace and a handler function, return a function that invokes
|
||||
// the handler only when e's namespace matches. For example, if the
|
||||
// namespace is "bs", it would match when e.namespace is "bs" or "bs.tab".
|
||||
// If the namespace is "bs.tab", it would match for "bs.tab", but not "bs".
|
||||
function filterEventsByNamespace(
|
||||
namespace: string,
|
||||
handler: (...handlerArgs: any[]) => void,
|
||||
...args: any[]
|
||||
) {
|
||||
const namespaceArr = namespace.split(".");
|
||||
|
||||
return function (this: HTMLElement, e: JQuery.TriggeredEvent) {
|
||||
const eventNamespace = e.namespace?.split(".") ?? [];
|
||||
|
||||
// If any of the namespace strings aren't present in this event, quit.
|
||||
for (let i = 0; i < namespaceArr.length; i++) {
|
||||
if (eventNamespace.indexOf(namespaceArr[i]) === -1) return;
|
||||
}
|
||||
|
||||
handler.apply(this, [namespaceArr, handler, ...args]);
|
||||
};
|
||||
}
|
||||
|
||||
// The size of each image may change either because the browser window was
|
||||
// resized, or because a tab was shown/hidden (hidden elements report size
|
||||
// of 0x0). It's OK to over-report sizes because the input pipeline will
|
||||
// filter out values that haven't changed.
|
||||
$(window).resize(debounce(500, sendImageSizeFns.regular));
|
||||
// Need to register callbacks for each Bootstrap 3 class.
|
||||
const bs3classes = [
|
||||
"modal",
|
||||
"dropdown",
|
||||
"tab",
|
||||
"tooltip",
|
||||
"popover",
|
||||
"collapse",
|
||||
];
|
||||
|
||||
$.each(bs3classes, function (idx, classname) {
|
||||
$(document.body).on(
|
||||
"shown.bs." + classname + ".sendImageSize",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendImageSizeFns.regular),
|
||||
);
|
||||
$(document.body).on(
|
||||
"shown.bs." +
|
||||
classname +
|
||||
".sendOutputHiddenState " +
|
||||
"hidden.bs." +
|
||||
classname +
|
||||
".sendOutputHiddenState",
|
||||
"*",
|
||||
filterEventsByNamespace("bs", sendOutputHiddenState),
|
||||
);
|
||||
});
|
||||
|
||||
// This is needed for Bootstrap 2 compatibility and for non-Bootstrap
|
||||
// related shown/hidden events (like conditionalPanel)
|
||||
$(document.body).on("shown.sendImageSize", "*", sendImageSizeFns.regular);
|
||||
$(document.body).on(
|
||||
"shown.sendOutputHiddenState hidden.sendOutputHiddenState",
|
||||
"*",
|
||||
sendOutputHiddenState,
|
||||
);
|
||||
doSendOutputInfo(true);
|
||||
sendOutputInfoFns.setSendMethod(inputBatchSender, doSendOutputInfo);
|
||||
|
||||
// Send initial pixel ratio, and update it if it changes
|
||||
initialValues[".clientdata_pixelratio"] = pixelRatio();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
shinyInitializeInputs,
|
||||
shinyUnbindAll,
|
||||
} from "./initedMethods";
|
||||
import { sendImageSizeFns } from "./sendImageSize";
|
||||
import { sendOutputInfoFns } from "./sendOutputInfo";
|
||||
|
||||
import type { WherePosition } from "./singletons";
|
||||
import { renderHtml as singletonsRenderHtml } from "./singletons";
|
||||
@@ -267,7 +267,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
// should have been applied synchronously.
|
||||
oldStyle.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendImageSizeFns.transitioned();
|
||||
sendOutputInfoFns.transitioned();
|
||||
};
|
||||
xhr.send();
|
||||
};
|
||||
@@ -327,7 +327,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
// base64-encoded and inlined into the href. We also add a dummy DOM
|
||||
// element that the CSS applies to. The dummy CSS includes a
|
||||
// transition, and when the `transitionend` event happens, we call
|
||||
// sendImageSizeFns.transitioned() and remove the old sheet. We also remove the
|
||||
// sendOutputInfoFns.transitioned() and remove the old sheet. We also remove the
|
||||
// dummy DOM element and dummy CSS content.
|
||||
//
|
||||
// The reason this works is because (we assume) that if multiple
|
||||
@@ -337,7 +337,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
//
|
||||
// Because it is common for multiple stylesheets to arrive close
|
||||
// together, but not on exactly the same tick, we call
|
||||
// sendImageSizeFns.transitioned(), which is debounced. Otherwise, it can result in
|
||||
// sendOutputInfoFns.transitioned(), which is debounced. Otherwise, it can result in
|
||||
// the same plot being redrawn multiple times with different
|
||||
// styling.
|
||||
$link.attr("onload", () => {
|
||||
@@ -350,7 +350,7 @@ function addStylesheetsAndRestyle(links: HTMLLinkElement[]): void {
|
||||
$dummyEl.one("transitionend", () => {
|
||||
$dummyEl.remove();
|
||||
removeSheet(oldSheet);
|
||||
sendImageSizeFns.transitioned();
|
||||
sendOutputInfoFns.transitioned();
|
||||
});
|
||||
$(document.body).append($dummyEl);
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { debounce, Debouncer } from "../time";
|
||||
|
||||
class SendImageSize {
|
||||
// This function gets defined in initShiny() and 'hoisted' so it can be reused
|
||||
// (to send CSS info) inside of Shiny.renderDependencies()
|
||||
regular!: () => void;
|
||||
transitioned!: () => void;
|
||||
|
||||
setImageSend(
|
||||
inputBatchSender: InputBatchSender,
|
||||
doSendImageSize: () => void,
|
||||
): Debouncer<typeof doSendImageSize> {
|
||||
const sendImageSizeDebouncer = new Debouncer(null, doSendImageSize, 0);
|
||||
|
||||
this.regular = function () {
|
||||
sendImageSizeDebouncer.normalCall();
|
||||
};
|
||||
|
||||
// Make sure sendImageSize actually gets called before the inputBatchSender
|
||||
// sends data to the server.
|
||||
inputBatchSender.lastChanceCallback.push(function () {
|
||||
if (sendImageSizeDebouncer.isPending())
|
||||
sendImageSizeDebouncer.immediateCall();
|
||||
});
|
||||
|
||||
// A version of sendImageSize which debounces for longer.
|
||||
this.transitioned = debounce(200, this.regular);
|
||||
|
||||
return sendImageSizeDebouncer;
|
||||
}
|
||||
}
|
||||
|
||||
const sendImageSizeFns = new SendImageSize();
|
||||
|
||||
export { sendImageSizeFns };
|
||||
77
srcts/src/shiny/sendOutputInfo.ts
Normal file
77
srcts/src/shiny/sendOutputInfo.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { debounce, Debouncer } from "../time";
|
||||
|
||||
type FlushableObserverCallback = (() => void) & {
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
isPending: () => boolean;
|
||||
};
|
||||
|
||||
class SendOutputInfo {
|
||||
regular!: () => void;
|
||||
transitioned!: () => void;
|
||||
#pendingObserverCallbacks = new Set<FlushableObserverCallback>();
|
||||
|
||||
setSendMethod(
|
||||
inputBatchSender: InputBatchSender,
|
||||
doSendOutputInfo: () => void,
|
||||
): Debouncer<typeof doSendOutputInfo> {
|
||||
const sendOutputInfoDebouncer = new Debouncer(null, doSendOutputInfo, 0);
|
||||
|
||||
this.regular = function () {
|
||||
sendOutputInfoDebouncer.normalCall();
|
||||
};
|
||||
|
||||
inputBatchSender.lastChanceCallback.push(() => {
|
||||
this.#pendingObserverCallbacks.forEach((callback) => callback.flush());
|
||||
|
||||
if (sendOutputInfoDebouncer.isPending())
|
||||
sendOutputInfoDebouncer.immediateCall();
|
||||
});
|
||||
|
||||
this.transitioned = debounce(200, this.regular);
|
||||
|
||||
return sendOutputInfoDebouncer;
|
||||
}
|
||||
|
||||
createObserverCallback(
|
||||
delayMs: number,
|
||||
callback: () => void,
|
||||
): FlushableObserverCallback {
|
||||
const debouncer = new Debouncer(
|
||||
null,
|
||||
() => {
|
||||
this.#pendingObserverCallbacks.delete(observerCallback);
|
||||
callback();
|
||||
},
|
||||
delayMs,
|
||||
);
|
||||
|
||||
const observerCallback: FlushableObserverCallback = Object.assign(
|
||||
() => {
|
||||
this.#pendingObserverCallbacks.add(observerCallback);
|
||||
debouncer.normalCall();
|
||||
},
|
||||
{
|
||||
cancel: () => {
|
||||
this.#pendingObserverCallbacks.delete(observerCallback);
|
||||
debouncer.cancel();
|
||||
},
|
||||
flush: () => {
|
||||
this.#pendingObserverCallbacks.delete(observerCallback);
|
||||
if (debouncer.isPending()) {
|
||||
debouncer.immediateCall();
|
||||
}
|
||||
},
|
||||
isPending: () => debouncer.isPending(),
|
||||
},
|
||||
);
|
||||
|
||||
return observerCallback;
|
||||
}
|
||||
}
|
||||
|
||||
const sendOutputInfoFns = new SendOutputInfo();
|
||||
|
||||
export { SendOutputInfo, sendOutputInfoFns };
|
||||
export type { FlushableObserverCallback };
|
||||
@@ -70,6 +70,8 @@ const messageHandlers: { [key: string]: Handler } = {};
|
||||
const customMessageHandlerOrder: string[] = [];
|
||||
const customMessageHandlers: { [key: string]: Handler } = {};
|
||||
|
||||
const conditionalShownClass = "shiny-conditional--shown";
|
||||
|
||||
// Adds Shiny (internal) message handler
|
||||
function addMessageHandler(type: string, handler: Handler) {
|
||||
if (messageHandlers[type]) {
|
||||
@@ -614,16 +616,16 @@ class ShinyApp {
|
||||
const nsPrefix = el.attr("data-ns-prefix") as string;
|
||||
const nsScope = this._narrowScope(scope, nsPrefix);
|
||||
const show = Boolean(condFunc(nsScope));
|
||||
const showing = el.css("display") !== "none";
|
||||
const showing = el.hasClass(conditionalShownClass);
|
||||
|
||||
if (show !== showing) {
|
||||
if (show) {
|
||||
el.trigger("show");
|
||||
el.show();
|
||||
el.addClass(conditionalShownClass);
|
||||
el.trigger("shown");
|
||||
} else {
|
||||
el.trigger("hide");
|
||||
el.hide();
|
||||
el.removeClass(conditionalShownClass);
|
||||
el.trigger("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
18
srcts/src/time/__tests__/debounce.test.ts
Normal file
18
srcts/src/time/__tests__/debounce.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { debounce } from "../debounce";
|
||||
|
||||
void test("debounce can cancel a pending callback before it fires", async () => {
|
||||
let calls = 0;
|
||||
const debounced = debounce(10, () => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
debounced();
|
||||
debounced.cancel();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
@@ -39,6 +39,10 @@ class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X> {
|
||||
this.args = args;
|
||||
this.$invoke();
|
||||
}
|
||||
cancel(): void {
|
||||
this.$clearTimer();
|
||||
this.args = null;
|
||||
}
|
||||
isPending(): boolean {
|
||||
return this.timerId !== null;
|
||||
}
|
||||
@@ -70,15 +74,21 @@ class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X> {
|
||||
// 900ms intervals will result in a single execution
|
||||
// of the underlying function, 1000ms after the 17th
|
||||
// call.
|
||||
type DebouncedFunction<T extends (...args: unknown[]) => void> = ((
|
||||
...args: Parameters<T>
|
||||
) => void) & {
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
function debounce<T extends (...args: unknown[]) => void>(
|
||||
threshold: number | undefined,
|
||||
func: T,
|
||||
): (...args: Parameters<T>) => void {
|
||||
): DebouncedFunction<T> {
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Do not alter `function()` into an arrow function.
|
||||
// The `this` context needs to be dynamically bound
|
||||
return function thisFunc(...args: Parameters<T>) {
|
||||
const debounced = function thisFunc(...args: Parameters<T>) {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
@@ -92,6 +102,16 @@ function debounce<T extends (...args: unknown[]) => void>(
|
||||
func.apply(thisFunc, args);
|
||||
}, threshold);
|
||||
};
|
||||
|
||||
debounced.cancel = function () {
|
||||
if (timerId !== null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export { debounce, Debouncer };
|
||||
export type { DebouncedFunction };
|
||||
|
||||
@@ -59,6 +59,16 @@ function getStyle(el: Element, styleProp: string): string | undefined {
|
||||
return x;
|
||||
}
|
||||
|
||||
function isVisible(el: HTMLElement): boolean {
|
||||
if (el.offsetWidth !== 0 || el.offsetHeight !== 0) {
|
||||
return true;
|
||||
}
|
||||
if (getStyle(el, "display") === "none") {
|
||||
return false;
|
||||
}
|
||||
return el.parentElement ? isVisible(el.parentElement) : true;
|
||||
}
|
||||
|
||||
// Convert a number to a string with leading zeros
|
||||
function padZeros(n: number, digits: number): string {
|
||||
let str = n.toString();
|
||||
@@ -421,6 +431,7 @@ export {
|
||||
isBS3,
|
||||
isnan,
|
||||
isShinyInDevMode,
|
||||
isVisible,
|
||||
makeResizeFilter,
|
||||
mapValues,
|
||||
mergeSort,
|
||||
|
||||
1
srcts/types/src/events/jQueryEvents.d.ts
vendored
1
srcts/types/src/events/jQueryEvents.d.ts
vendored
@@ -13,7 +13,6 @@ declare global {
|
||||
on(events: EvtPrefix<"mousedown2">, handler: EvtFn<JQuery.MouseDownEvent>): this;
|
||||
on(events: EvtPrefix<"mouseup">, handler: EvtFn<JQuery.MouseUpEvent>): this;
|
||||
on(events: EvtPrefix<"resize">, handler: EvtFn<JQuery.ResizeEvent>): this;
|
||||
on(events: `shown.bs.${string}.sendImageSize`, selector: string, handler: (this: HTMLElement, e: JQuery.EventHandlerBase<HTMLElement, any>) => void): this;
|
||||
}
|
||||
}
|
||||
export {};
|
||||
|
||||
4
srcts/types/src/shiny/bind.d.ts
vendored
4
srcts/types/src/shiny/bind.d.ts
vendored
@@ -7,8 +7,6 @@ type BindInputsCtx = {
|
||||
inputsRate: InputRateDecorator;
|
||||
inputBindings: BindingRegistry<InputBinding>;
|
||||
outputBindings: BindingRegistry<OutputBinding>;
|
||||
sendOutputHiddenState: () => void;
|
||||
maybeAddThemeObserver: (el: HTMLElement) => void;
|
||||
initDeferredIframes: () => void;
|
||||
outputIsRecalculating: (id: string) => boolean;
|
||||
};
|
||||
@@ -23,7 +21,7 @@ declare function bindInputs(shinyCtx: BindInputsCtx, scope?: BindScope): {
|
||||
};
|
||||
};
|
||||
declare function _bindAll(shinyCtx: BindInputsCtx, scope: BindScope): Promise<ReturnType<typeof bindInputs>>;
|
||||
declare function unbindAll(shinyCtx: BindInputsCtx, scope: BindScope, includeSelf?: boolean): void;
|
||||
declare function unbindAll(scope: BindScope, includeSelf?: boolean): void;
|
||||
declare function bindAll(shinyCtx: BindInputsCtx, scope: BindScope): Promise<void>;
|
||||
export { _bindAll, bindAll, unbindAll };
|
||||
export type { BindInputsCtx, BindScope };
|
||||
|
||||
9
srcts/types/src/shiny/sendImageSize.d.ts
vendored
9
srcts/types/src/shiny/sendImageSize.d.ts
vendored
@@ -1,9 +0,0 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { Debouncer } from "../time";
|
||||
declare class SendImageSize {
|
||||
regular: () => void;
|
||||
transitioned: () => void;
|
||||
setImageSend(inputBatchSender: InputBatchSender, doSendImageSize: () => void): Debouncer<typeof doSendImageSize>;
|
||||
}
|
||||
declare const sendImageSizeFns: SendImageSize;
|
||||
export { sendImageSizeFns };
|
||||
17
srcts/types/src/shiny/sendOutputInfo.d.ts
vendored
Normal file
17
srcts/types/src/shiny/sendOutputInfo.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { InputBatchSender } from "../inputPolicies";
|
||||
import { Debouncer } from "../time";
|
||||
type FlushableObserverCallback = (() => void) & {
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
isPending: () => boolean;
|
||||
};
|
||||
declare class SendOutputInfo {
|
||||
#private;
|
||||
regular: () => void;
|
||||
transitioned: () => void;
|
||||
setSendMethod(inputBatchSender: InputBatchSender, doSendOutputInfo: () => void): Debouncer<typeof doSendOutputInfo>;
|
||||
createObserverCallback(delayMs: number, callback: () => void): FlushableObserverCallback;
|
||||
}
|
||||
declare const sendOutputInfoFns: SendOutputInfo;
|
||||
export { SendOutputInfo, sendOutputInfoFns };
|
||||
export type { FlushableObserverCallback };
|
||||
7
srcts/types/src/time/debounce.d.ts
vendored
7
srcts/types/src/time/debounce.d.ts
vendored
@@ -10,9 +10,14 @@ declare class Debouncer<X extends AnyVoidFunction> implements InputRatePolicy<X>
|
||||
constructor(target: InputPolicy | null, func: X, delayMs: number | undefined);
|
||||
normalCall(...args: Parameters<X>): void;
|
||||
immediateCall(...args: Parameters<X>): void;
|
||||
cancel(): void;
|
||||
isPending(): boolean;
|
||||
$clearTimer(): void;
|
||||
$invoke(): void;
|
||||
}
|
||||
declare function debounce<T extends (...args: unknown[]) => void>(threshold: number | undefined, func: T): (...args: Parameters<T>) => void;
|
||||
type DebouncedFunction<T extends (...args: unknown[]) => void> = ((...args: Parameters<T>) => void) & {
|
||||
cancel: () => void;
|
||||
};
|
||||
declare function debounce<T extends (...args: unknown[]) => void>(threshold: number | undefined, func: T): DebouncedFunction<T>;
|
||||
export { debounce, Debouncer };
|
||||
export type { DebouncedFunction };
|
||||
|
||||
3
srcts/types/src/utils/index.d.ts
vendored
3
srcts/types/src/utils/index.d.ts
vendored
@@ -5,6 +5,7 @@ declare function escapeHTML(str: string): string;
|
||||
declare function randomId(): string;
|
||||
declare function strToBool(str: string): boolean | undefined;
|
||||
declare function getStyle(el: Element, styleProp: string): string | undefined;
|
||||
declare function isVisible(el: HTMLElement): boolean;
|
||||
declare function padZeros(n: number, digits: number): string;
|
||||
declare function roundSignif(x: number, digits?: number): number;
|
||||
declare function parseDate(dateString: string): Date;
|
||||
@@ -34,4 +35,4 @@ declare function getComputedLinkColor(el: HTMLElement): string;
|
||||
declare function isBS3(): boolean;
|
||||
declare function toLowerCase<T extends string>(str: T): Lowercase<T>;
|
||||
declare function isShinyInDevMode(): boolean;
|
||||
export { $escape, _equal, asArray, compareVersion, equal, escapeHTML, formatDateUTC, getBoundingClientSizeBeforeZoom, getComputedLinkColor, getStyle, hasDefinedProperty, hasOwnProperty, isBS3, isnan, isShinyInDevMode, makeResizeFilter, mapValues, mergeSort, padZeros, parseDate, pixelRatio, randomId, roundSignif, scopeExprToFunc, strToBool, toLowerCase, updateLabel, };
|
||||
export { $escape, _equal, asArray, compareVersion, equal, escapeHTML, formatDateUTC, getBoundingClientSizeBeforeZoom, getComputedLinkColor, getStyle, hasDefinedProperty, hasOwnProperty, isBS3, isnan, isShinyInDevMode, isVisible, makeResizeFilter, mapValues, mergeSort, padZeros, parseDate, pixelRatio, randomId, roundSignif, scopeExprToFunc, strToBool, toLowerCase, updateLabel, };
|
||||
|
||||
10
tsconfig.eslint.json
Normal file
10
tsconfig.eslint.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"srcts/types/"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user