Compare commits

...

15 Commits

Author SHA1 Message Date
Carson
923c1b2450 Handle the non-bslib case; better comments 2021-06-14 16:55:39 -05:00
Carson
914dc71d67 Attach dependencies to render hook object; bump version 2021-06-14 16:55:39 -05:00
Carson
a13ca9fd3a bump htmltools requirement 2021-06-14 16:55:39 -05:00
Carson
b8b34370da Close #3401: bootstrapLib() now always sets state on render and cleans up post static render 2021-06-14 16:55:39 -05:00
Carson Sievert
bcb7cde44b insertTab() now handles position correctly when target is NULL (#3404)
* Close #3403: insertTab() now handles position correctly when target is NULL

* Have insertTab()'s target default to NULL

* yarn tsc (GitHub Actions)

* yarn build (GitHub Actions)

Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
2021-06-14 15:51:38 -05:00
Carson Sievert
052c9458b7 yarn add node-gyp; yarn build (#3424) 2021-06-14 15:51:03 -05:00
Barret Schloerke
3fe8c27d21 Export TypeScript type definitions to local folder (#3418) 2021-06-14 14:25:05 -04:00
Barret Schloerke
1dd256b210 TypeScript: Remove any types / improve type definitions (#3414) 2021-06-14 14:22:39 -04:00
Carson Sievert
dc9c6ae769 Better color constrasting in sliderInput() (#3366)
* Better color constrasting in sliderInput()

Closes https://github.com/rstudio/bslib/issues/228

* Update build script; recompile

* bslib tabsets now include data-bs-toggle
2021-06-14 12:48:57 -05:00
Carson Sievert
2cdafed2e0 Use ggplot2::get_alt_text() if available to provide better default alt text (#3398)
* Close #3397: Use ggplot2::get_alt_text() if available to provide more informative default alt text for ggplots in renderPlot()

* Update R/render-plot.R

Co-authored-by: Winston Chang <winston@stdout.org>

* better Rd docs

* make logic more self-contained

* Add news

Co-authored-by: Winston Chang <winston@stdout.org>
2021-06-14 10:22:07 -05:00
Barret Schloerke
b4caa9137d Distribute TypeScript code into separate files (#3317)
Co-authored-by: Barret Schloerke <schloerke@gmail.com>
Co-authored-by: Carson Sievert <cpsievert1@gmail.com>
2021-06-09 14:54:47 -04:00
Carson Sievert
dcca77c936 Fix tab input value updating for BS4 dropdowns (#3412)
* Fix tab input value updating for BS4 dropdowns

* Add comments

* yarn build (GitHub Actions)

* Better comment

* yarn lint (GitHub Actions)

* yarn build (GitHub Actions)

Co-authored-by: Barret Schloerke <barret@rstudio.com>
Co-authored-by: schloerke <schloerke@users.noreply.github.com>
Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
2021-06-02 15:55:32 -05:00
Carson Sievert
871b1baacc Follow up to #3410: bump version and update news (#3411) 2021-06-02 13:03:09 -05:00
Carson Sievert
4deb699066 Bootstrap 5 support (#3410)
* Bootstrap 5 support for modals & showcase mode

* selectizeInput() BS5 compatibility

* Both BS4 and 5 define window.bootstrap

* Document (GitHub Actions)

Co-authored-by: cpsievert <cpsievert@users.noreply.github.com>
2021-06-02 12:36:04 -05:00
Carson Sievert
ccc8e053c6 Use bslib's new nav() api to implement tabPanel() and friends (#3388)
* Use bslib's new nav() api to implement tabPanel() and friends

* bslib won't be re-exporting prepend/append tab since they've been superceded by insertTab()

* Update DESCRIPTION

* Use the new bslib::page_navbar()

* Leverage bslib::page_navbar()'s more intelligent title->windowTitle handling

Closes #2310

* fix name change

* Make sure navbarPage() isn't browsable by default
2021-06-02 12:10:41 -05:00
218 changed files with 26319 additions and 16289 deletions

View File

@@ -129,12 +129,16 @@ jobs:
tree src
yarn install --immutable && yarn build
git add ./src && git commit -m 'yarn lint (GitHub Actions)' || echo "No yarn lint changes to commit"
git add ./src_d && git commit -m 'yarn tsc (GitHub Actions)' || echo "No type definition changes to commit"
git add ../inst && git commit -m 'yarn build (GitHub Actions)' || echo "No yarn build changes to commit"
- name: Check JS build is latest
run: |
./tools/checkJSCurrent.sh
if [ -n "$(git status --porcelain)" ]
then
git status --porcelain
>&2 echo "The above files changed when we built the JavaScript assets."
exit 1
else
echo "No difference detected; TypeScript build is current."
fi
- name: Git Push (PR)
uses: r-lib/actions/pr-push@master

View File

@@ -1,7 +1,7 @@
Package: shiny
Type: Package
Title: Web Application Framework for R
Version: 1.6.0.9000
Version: 1.6.0.9022
Authors@R: c(
person("Winston", "Chang", role = c("aut", "cre"), email = "winston@rstudio.com", comment = c(ORCID = "0000-0002-1576-2126")),
person("Joe", "Cheng", role = "aut", email = "joe@rstudio.com"),
@@ -79,7 +79,7 @@ Imports:
jsonlite (>= 0.9.16),
xtable,
fontawesome (>= 0.2.1),
htmltools (>= 0.5.1.9003),
htmltools (>= 0.5.1.9006),
R6 (>= 2.0),
sourcetools,
later (>= 1.0.0),
@@ -91,7 +91,7 @@ Imports:
withr,
commonmark (>= 1.7),
glue (>= 1.3.2),
bslib (>= 0.2.4.9003),
bslib (>= 0.2.5.9002),
cachem,
ellipsis,
lifecycle (>= 0.2.0)
@@ -199,7 +199,11 @@ Collate:
'test.R'
'update-input.R'
'utils-lang.R'
'version_bs_date_picker.R'
'version_ion_range_slider.R'
'version_jquery.R'
'version_selectize.R'
'version_strftime.R'
'viewer.R'
RoxygenNote: 7.1.1
Encoding: UTF-8

16
NEWS.md
View File

@@ -9,17 +9,19 @@ shiny 1.6.0.9000
### New features and improvements
* All uses of `list(...)` have been replaced with `rlang::list2(...)`. This means that you can use trailing `,` without error and use rlang's `!!!` operator to "splice" a list of argument values into `...`. We think this'll be particularly useful for passing a list of `tabPanel()` to their consumers (i.e., `tabsetPanel()`, `navbarPage()`, etc). For example, `tabs <- list(tabPanel("A", "a"), tabPanel("B", "b")); navbarPage(!!!tabs)`. (#3315 and #3328)
* Bootstrap 5 support. (#3410 and rstudio/bslib#304)
* As explained [here](https://rstudio.github.io/bslib/index.html#basic-usage), to opt-in to Bootstrap 5, provide `bslib::bs_theme(version = 5)` to a page layout function with a `theme` argument (e.g., `fluidPage()`, `navbarPage()`, etc).
* Numerous improvements tabset panels (i.e., `tabPanel()`, `navbarMenu()`, `tabsetPanel()`, `navbarPage()`, etc) (#3315):
* Closed #3322: `tabsetPanel()` and `navlistPanel()` gain `header`/`footer` arguments (inspired by `navbarPage()`'s already existing `header`/`footer`), making it easier to include content that should appear on every tab.
* Closed #3313 and #1823: More informative error when non-`tabPanel()`/`shiny.tag` objects are supplied to `...`.
* Closed #3321: New informative warning when `shiny.tag` object(s) are supplied to `...`. In this case we will continue to create an "empty" nav item and include the content on every tab, but the warning will mention the (new) `header`/`footer` args, which is likely what the user wants.
* Closed #3320: The HTML markup that `tabPanel()` et. al generate (for Bootstrap nav) is now Bootstrap 4+ compliant when used with `theme = bslib::bs_theme()`.
* Closed #1928: `NULL` values are now dropped instead of producing an empty nav item.
* Closed #3322, #3313, #1823, #3321, #3320, #1928, and #2310: Various improvements to `navbarPage()`, `tabsetPanel()`, `tabPanel()`, `navbarMenu()`, etc. Also, these functions are now powered by the `{bslib}` package's new `nav()` API (consider using `{bslib}`'s API to create better looking and more fully featured navs). (#3388)
* All uses of `list(...)` have been replaced with `rlang::list2(...)`. This means that you can use trailing `,` without error and use rlang's `!!!` operator to "splice" a list of argument values into `...`. We think this'll be particularly useful for passing a list of `tabPanel()` to their consumers (i.e., `tabsetPanel()`, `navbarPage()`, etc). For example, `tabs <- list(tabPanel("A", "a"), tabPanel("B", "b")); navbarPage(!!!tabs)`. (#3315 and #3328)
* `icon(lib="fontawesome")` is now powered by the `{fontawesome}` package, which will make it easier to use the latest FA icons in the future (by updating the `{fontawesome}` package). (#3302)
* Closed #3397: `renderPlot()` new uses `ggplot2::get_alt_text()` to inform an `alt` text default (for `{ggplot2}` plots). (#3398)
* `modalDialog()` gains support for `size = "xl"`. (#3410)
### Other improvements
* Shiny's core JavaScript code was converted to TypeScript. For the latest development information, please see the [README.md in `./srcts`](https://github.com/rstudio/shiny/tree/master/srcts). (#3296)

View File

@@ -50,7 +50,7 @@ bootstrapPage <- function(..., title = NULL, theme = NULL, lang = NULL) {
# the tagList() contents to avoid breaking user code that makes assumptions
# about the return value https://github.com/rstudio/shiny/issues/3235
if (is_bs_theme(theme)) {
args <- c(bootstrapLib(theme), args)
args <- list(bootstrapLib(theme), args)
ui <- do.call(tagList, args)
} else {
ui <- do.call(tagList, args)
@@ -82,53 +82,30 @@ getLang <- function(ui) {
#' @inheritParams bootstrapPage
#' @export
bootstrapLib <- function(theme = NULL) {
tagFunction(function() {
if (isRunning()) {
# In the non-bslib case, return static HTML dependencies
if (!is_bs_theme(theme)) {
return(bootstrapDependency(theme))
}
# To support static rendering of Bootstrap version dependent markup (e.g.,
# tabsetPanel()), setCurrentTheme() at the start of the render (since
# bootstrapLib() comes first in bootstrapPage(), all other UI should then know
# what version of Bootstrap is being used). Then restore state after render as
# long as shiny isn't running (in that case, since setCurrentTheme() uses
# shinyOptions(), state will be automatically be restored when the app exits)
oldTheme <- NULL
tagList(
.renderHook = function(x) {
oldTheme <<- getCurrentTheme()
setCurrentTheme(theme)
# For refreshing Bootstrap CSS when session$setCurrentTheme() happens
if (isRunning()) registerThemeDependency(bs_theme_deps)
attachDependencies(x, bslib::bs_theme_dependencies(theme))
},
.postRenderHook = function() {
if (!isRunning()) setCurrentTheme(oldTheme)
NULL
}
# If we're not compiling Bootstrap Sass (from bslib), return the
# static Bootstrap build.
if (!is_bs_theme(theme)) {
# We'll enter here if `theme` is the path to a .css file, like that
# provided by `shinythemes::shinytheme("darkly")`.
return(bootstrapDependency(theme))
}
# Make bootstrap Sass available so other tagFunction()s (e.g.,
# sliderInput() et al) can resolve their HTML dependencies at render time
# using getCurrentTheme(). Note that we're making an implicit assumption
# that this tagFunction() executes *before* all other tagFunction()s; but
# that should be fine considering that, DOM tree order is preorder,
# depth-first traversal, and at least in the bootstrapPage(theme) case, we
# have control over the relative ordering.
# https://dom.spec.whatwg.org/#concept-tree
# https://stackoverflow.com/a/16113998/1583084
#
# Note also that since this is shinyOptions() (and not options()), the
# option is automatically reset when the app (or session) exits
if (isRunning()) {
registerThemeDependency(bs_theme_deps)
} else {
# Technically, this a potential issue (someone trying to execute/render
# bootstrapLib outside of a Shiny app), but it seems that, in that case,
# you likely have other problems, since sliderInput() et al. already assume
# that Shiny is the one doing the rendering
#warning(
# "It appears `shiny::bootstrapLib()` was rendered outside of an Shiny ",
# "application context, likely by calling `as.tags()`, `as.character()`, ",
# "or `print()` directly on `bootstrapLib()` or UI components that may ",
# "depend on it (e.g., `fluidPage()`, etc). For 'themable' UI components ",
# "(e.g., `sliderInput()`, `selectInput()`, `dateInput()`, etc) to style ",
# "themselves based on the Bootstrap theme, make sure `bootstrapLib()` is ",
# "provided directly to the UI and that the UI is provided direction to ",
# "`shinyApp()` (or `runApp()`)", call. = FALSE
#)
}
bslib::bs_theme_dependencies(theme)
})
)
}
# This is defined outside of bootstrapLib() because registerThemeDependency()
@@ -380,8 +357,10 @@ collapseSizes <- function(padding) {
#' (useful for viewing on smaller touchscreen device)
#' @param fluid `TRUE` to use a fluid layout. `FALSE` to use a fixed
#' layout.
#' @param windowTitle The title that should be displayed by the browser window.
#' Useful if `title` is not a string.
#' @param windowTitle the browser window title (as a character string). The
#' default value, `NA`, means to use any character strings that appear in
#' `title` (if none are found, the host URL of the page is displayed by
#' default).
#' @inheritParams bootstrapPage
#' @param icon Optional icon to appear on a `navbarMenu` tab.
#'
@@ -425,70 +404,18 @@ navbarPage <- function(title,
collapsible = FALSE,
fluid = TRUE,
theme = NULL,
windowTitle = title,
windowTitle = NA,
lang = NULL) {
# alias title so we can avoid conflicts w/ title in withTags
pageTitle <- title
# navbar class based on options
# TODO: tagFunction() the navbar logic?
navbarClass <- "navbar navbar-default"
position <- match.arg(position)
if (!is.null(position))
navbarClass <- paste0(navbarClass, " navbar-", position)
if (inverse)
navbarClass <- paste(navbarClass, "navbar-inverse")
if (!is.null(id))
selected <- restoreInput(id = id, default = selected)
# build the tabset
tabset <- buildTabset(..., ulClass = "nav navbar-nav", id = id, selected = selected)
containerClass <- paste0("container", if (fluid) "-fluid")
# built the container div dynamically to support optional collapsibility
if (collapsible) {
navId <- paste0("navbar-collapse-", p_randomInt(1000, 10000))
containerDiv <- div(class=containerClass,
div(class="navbar-header",
tags$button(type="button", class="navbar-toggle collapsed",
`data-toggle`="collapse", `data-target`=paste0("#", navId),
span(class="sr-only", "Toggle navigation"),
span(class="icon-bar"),
span(class="icon-bar"),
span(class="icon-bar")
),
span(class="navbar-brand", pageTitle)
),
div(class="navbar-collapse collapse", id=navId, tabset$navList)
)
} else {
containerDiv <- div(class=containerClass,
div(class="navbar-header",
span(class="navbar-brand", pageTitle)
),
tabset$navList
)
}
# build the main tab content div
contentDiv <- div(class=containerClass)
if (!is.null(header))
contentDiv <- tagAppendChild(contentDiv, div(class="row", header))
contentDiv <- tagAppendChild(contentDiv, tabset$content)
if (!is.null(footer))
contentDiv <- tagAppendChild(contentDiv, div(class="row", footer))
# build the page
bootstrapPage(
title = windowTitle,
remove_first_class(bslib::page_navbar(
..., title = title, id = id, selected = selected,
position = match.arg(position),
header = header, footer = footer,
inverse = inverse, collapsible = collapsible,
fluid = fluid,
theme = theme,
lang = lang,
tags$nav(class=navbarClass, role="navigation", containerDiv),
contentDiv
)
window_title = windowTitle,
lang = lang
))
}
#' @param menuName A name that identifies this `navbarMenu`. This
@@ -498,19 +425,7 @@ navbarPage <- function(title,
#' @rdname navbarPage
#' @export
navbarMenu <- function(title, ..., menuName = title, icon = NULL) {
icon <- prepTabIcon(icon)
structure(list(title = title,
menuName = menuName,
tabs = list2(...),
# Here for legacy reasons
# https://github.com/cran/miniUI/blob/74c87d3/R/layout.R#L369
iconClass = tagGetAttribute(icon, "class"),
icon = icon),
class = "shiny.navbarmenu")
}
isNavbarMenu <- function(x) {
inherits(x, "shiny.navbarmenu")
bslib::nav_menu(title, ..., value = menuName, icon = icon)
}
#' Create a well panel
@@ -645,39 +560,14 @@ helpText <- function(...) {
#' @export
#' @describeIn tabPanel Create a tab panel that can be included within a [tabsetPanel()] or a [navbarPage()].
tabPanel <- function(title, ..., value = title, icon = NULL) {
icon <- prepTabIcon(icon)
pane <- div(
class = "tab-pane",
title = title,
`data-value` = value,
# Here for legacy reasons
# https://github.com/cran/miniUI/blob/74c87d/R/layout.R#L395
`data-icon-class` = tagGetAttribute(icon, "class"),
...
)
attr(pane, "_shiny_icon") <- icon
pane
}
isTabPanel <- function(x) {
if (!inherits(x, "shiny.tag")) return(FALSE)
class <- tagGetAttribute(x, "class") %||% ""
"tab-pane" %in% strsplit(class, "\\s+")[[1]]
bslib::nav(title, ..., value = value, icon = icon)
}
#' @export
#' @describeIn tabPanel Create a tab panel that drops the title argument.
#' This function should be used within `tabsetPanel(type = "hidden")`. See [tabsetPanel()] for example usage.
tabPanelBody <- function(value, ..., icon = NULL) {
if (
!is.character(value) ||
length(value) != 1 ||
any(is.na(value)) ||
nchar(value) == 0
) {
stop("`value` must be a single, non-empty string value")
}
tabPanel(title = NULL, ..., value = value, icon = icon)
bslib::nav_content(value, ..., icon = icon)
}
#' Create a tabset panel
@@ -753,20 +643,17 @@ tabsetPanel <- function(...,
header = NULL,
footer = NULL) {
if (!is.null(id))
selected <- restoreInput(id = id, default = selected)
func <- switch(
match.arg(type),
tabs = bslib::navs_tab,
pills = bslib::navs_pill,
hidden = bslib::navs_hidden
)
type <- match.arg(type)
tabset <- buildTabset(..., ulClass = paste0("nav nav-", type), id = id, selected = selected)
tags$div(
class = "tabbable",
!!!dropNulls(list(
tabset$navList,
header,
tabset$content,
footer
))
# bslib adds a class to make the content browsable() by default,
# but that's probably too big of a change for shiny
remove_first_class(
func(..., id = id, selected = selected, header = header, footer = footer)
)
}
@@ -822,275 +709,18 @@ navlistPanel <- function(...,
well = TRUE,
fluid = TRUE,
widths = c(4, 8)) {
if (!is.null(id))
selected <- restoreInput(id = id, default = selected)
tabset <- buildTabset(
..., ulClass = "nav nav-pills nav-stacked",
textFilter = function(text) tags$li(class = "navbar-brand", text),
id = id, selected = selected
)
row <- if (fluid) fluidRow else fixedRow
row(
column(widths[[1]], class = if (well) "well", tabset$navList),
column(
widths[[2]],
!!!dropNulls(list(header, tabset$content, footer))
)
)
remove_first_class(bslib::navs_pill_list(
..., id = id, selected = selected,
header = header, footer = footer,
well = well, fluid = fluid, widths = widths
))
}
# Helpers to build tabsetPanels (& Co.) and their elements
markTabAsSelected <- function(x) {
attr(x, "selected") <- TRUE
remove_first_class <- function(x) {
class(x) <- class(x)[-1]
x
}
isTabSelected <- function(x) {
isTRUE(attr(x, "selected", exact = TRUE))
}
containsSelectedTab <- function(tabs) {
any(vapply(tabs, isTabSelected, logical(1)))
}
findAndMarkSelectedTab <- function(tabs, selected, foundSelected) {
tabs <- lapply(tabs, function(x) {
if (foundSelected || is.character(x)) {
# Strings are not selectable items
} else if (isNavbarMenu(x)) {
# Recur for navbarMenus
res <- findAndMarkSelectedTab(x$tabs, selected, foundSelected)
x$tabs <- res$tabs
foundSelected <<- res$foundSelected
} else {
# Base case: regular tab item. If the `selected` argument is
# provided, check for a match in the existing tabs; else,
# mark first available item as selected
if (is.null(selected)) {
foundSelected <<- TRUE
x <- markTabAsSelected(x)
} else {
tabValue <- x$attribs$`data-value` %||% x$attribs$title
if (identical(selected, tabValue)) {
foundSelected <<- TRUE
x <- markTabAsSelected(x)
}
}
}
return(x)
})
return(list(tabs = tabs, foundSelected = foundSelected))
}
prepTabIcon <- function(x = NULL) {
if (is.null(x)) return(NULL)
if (!inherits(x, "shiny.tag")) {
stop(
"`icon` must be a `shiny.tag` object. ",
"Try passing `icon()` (or `tags$i()`) to the `icon` parameter.",
call. = FALSE
)
}
is_fa <- grepl("fa-", tagGetAttribute(x, "class") %||% "", fixed = TRUE)
if (!is_fa) {
return(x)
}
# for font-awesome we specify fixed-width
tagAppendAttributes(x, class = "fa-fw")
}
# Text filter for navbarMenu's (plain text) separators
navbarMenuTextFilter <- function(text) {
if (grepl("^\\-+$", text)) tags$li(class = "divider")
else tags$li(class = "dropdown-header", text)
}
# This function is called internally by navbarPage, tabsetPanel
# and navlistPanel
buildTabset <- function(..., ulClass, textFilter = NULL, id = NULL,
selected = NULL, foundSelected = FALSE) {
tabs <- dropNulls(list2(...))
res <- findAndMarkSelectedTab(tabs, selected, foundSelected)
tabs <- res$tabs
foundSelected <- res$foundSelected
# add input class if we have an id
if (!is.null(id)) ulClass <- paste(ulClass, "shiny-tab-input")
if (anyNamed(tabs)) {
nms <- names(tabs)
nms <- nms[nzchar(nms)]
stop("Tabs should all be unnamed arguments, but some are named: ",
paste(nms, collapse = ", "))
}
tabsetId <- p_randomInt(1000, 10000)
tabs <- lapply(seq_len(length(tabs)), buildTabItem,
tabsetId = tabsetId, foundSelected = foundSelected,
tabs = tabs, textFilter = textFilter)
tabNavList <- tags$ul(class = ulClass, id = id,
`data-tabsetid` = tabsetId, !!!lapply(tabs, "[[", "liTag"))
tabContent <- tags$div(class = "tab-content",
`data-tabsetid` = tabsetId, !!!lapply(tabs, "[[", "divTag"))
list(navList = tabNavList, content = tabContent)
}
# Builds tabPanel/navbarMenu items (this function used to be
# declared inside the buildTabset() function and it's been
# refactored for clarity and reusability). Called internally
# by buildTabset.
buildTabItem <- function(index, tabsetId, foundSelected, tabs = NULL,
divTag = NULL, textFilter = NULL) {
divTag <- divTag %||% tabs[[index]]
# Handles navlistPanel() headers and dropdown dividers
if (is.character(divTag) && !is.null(textFilter)) {
return(list(liTag = textFilter(divTag), divTag = NULL))
}
if (isNavbarMenu(divTag)) {
# tabPanelMenu item: build the child tabset
tabset <- buildTabset(
!!!divTag$tabs, ulClass = "dropdown-menu",
textFilter = navbarMenuTextFilter,
foundSelected = foundSelected
)
return(buildDropdown(divTag, tabset))
}
if (isTabPanel(divTag)) {
return(buildNavItem(divTag, tabsetId, index))
}
# The behavior is undefined at this point, so construct a condition message
msg <- paste0(
"Expected a collection `tabPanel()`s",
if (is.null(textFilter)) " and `navbarMenu()`.",
if (!is.null(textFilter)) ", `navbarMenu()`, and/or character strings.",
" Consider using `header` or `footer` if you wish to place content above (or below) every panel's contents"
)
# Luckily this case has never worked, so it's safe to throw here
# https://github.com/rstudio/shiny/issues/3313
if (!inherits(divTag, "shiny.tag")) {
stop(msg, call. = FALSE)
}
# Unfortunately, this 'off-label' use case creates an 'empty' nav and includes
# the divTag content on every tab. There shouldn't be any reason to be relying on
# this behavior since we now have pre/post arguments, so throw a warning, but still
# support the use case since we don't make breaking changes
warning(msg, call. = FALSE)
return(buildNavItem(divTag, tabsetId, index))
}
buildNavItem <- function(divTag, tabsetId, index) {
id <- paste("tab", tabsetId, index, sep = "-")
# Get title attribute directory (not via tagGetAttribute()) so that contents
# don't get passed to as.character().
# https://github.com/rstudio/shiny/issues/3352
title <- divTag$attribs[["title"]]
value <- divTag$attribs[["data-value"]]
active <- isTabSelected(divTag)
divTag <- tagAppendAttributes(divTag, class = if (active) "active")
divTag$attribs$id <- id
divTag$attribs$title <- NULL
list(
divTag = divTag,
liTag = tagAddRenderHook(
liTag(id, title, value, attr(divTag, "_shiny_icon")),
function(x) {
if (isTRUE(getCurrentThemeVersion() >= 4)) {
tagQuery(x)$
addClass("nav-item")$
find("a")$
addClass(c("nav-link", if (active) "active"))$
allTags()
} else {
tagAppendAttributes(x, class = if (active) "active")
}
}
)
)
}
liTag <- function(id, title, value, icon) {
tags$li(
tags$a(
href = paste0("#", id),
`data-toggle` = "tab",
`data-value` = value,
icon, title
)
)
}
buildDropdown <- function(divTag, tabset) {
navList <- tagAddRenderHook(
tabset$navList,
function(x) {
if (isTRUE(getCurrentThemeVersion() >= 4)) {
tagQuery(x)$
find(".nav-item")$
removeClass("nav-item")$
find(".nav-link")$
removeClass("nav-link")$
addClass("dropdown-item")$
allTags()
} else {
x
}
}
)
active <- containsSelectedTab(divTag$tabs)
dropdown <- tags$li(
class = "dropdown",
tags$a(
href = "#",
class = "dropdown-toggle",
`data-toggle` = "dropdown",
`data-value` = divTag$menuName,
divTag$icon,
divTag$title,
tags$b(class = "caret")
),
navList,
.renderHook = function(x) {
if (isTRUE(getCurrentThemeVersion() >= 4)) {
tagQuery(x)$
addClass("nav-item")$
find(".dropdown-toggle")$
addClass("nav-link")$
allTags()
} else {
x
}
}
)
list(
divTag = tabset$content$children,
liTag = dropdown
)
}
#' Create a text output element
#'
#' Render a reactive output variable as text within an application page.

View File

@@ -133,13 +133,11 @@ dateInput <- function(inputId, label, value = NULL, min = NULL, max = NULL,
}
datePickerVersion <- "1.9.0"
datePickerDependency <- function(theme) {
list(
htmlDependency(
name = "bootstrap-datepicker-js",
version = datePickerVersion,
version = version_bs_date_picker,
src = c(href = "shared/datepicker"),
script = if (getOption("shiny.minified", TRUE)) "js/bootstrap-datepicker.min.js"
else "js/bootstrap-datepicker.js",
@@ -158,7 +156,7 @@ datePickerCSS <- function(theme) {
if (!is_bs_theme(theme)) {
return(htmlDependency(
name = "bootstrap-datepicker-css",
version = datePickerVersion,
version = version_bs_date_picker,
src = c(href = "shared/datepicker"),
stylesheet = "css/bootstrap-datepicker3.min.css"
))
@@ -170,7 +168,7 @@ datePickerCSS <- function(theme) {
input = sass::sass_file(scss_file),
theme = theme,
name = "bootstrap-datepicker",
version = datePickerVersion,
version = version_bs_date_picker,
cache_key_extra = shinyPackageVersion()
)
}

View File

@@ -243,19 +243,14 @@ selectizeDependency <- function() {
}
selectizeDependencyFunc <- function(theme) {
selectizeVersion <- "0.12.4"
if (!is_bs_theme(theme)) {
return(selectizeStaticDependency(selectizeVersion))
return(selectizeStaticDependency(version_selectize))
}
selectizeDir <- system.file(package = "shiny", "www/shared/selectize/")
bs_version <- bslib::theme_version(theme)
stylesheet <- file.path(
selectizeDir, "scss",
if ("3" %in% bslib::theme_version(theme)) {
"selectize.bootstrap3.scss"
} else {
"selectize.bootstrap4.scss"
}
selectizeDir, "scss", paste0("selectize.bootstrap", bs_version, ".scss")
)
# It'd be cleaner to ship the JS in a separate, href-based,
# HTML dependency (which we currently do for other themable widgets),
@@ -271,7 +266,7 @@ selectizeDependencyFunc <- function(theme) {
input = sass::sass_file(stylesheet),
theme = theme,
name = "selectize",
version = selectizeVersion,
version = version_selectize,
cache_key_extra = shinyPackageVersion(),
.dep_args = list(script = script)
)

View File

@@ -201,18 +201,16 @@ sliderInput <- function(inputId, label, min, max, value, step = NULL,
}
ionRangeSliderVersion <- "2.3.1"
ionRangeSliderDependency <- function() {
list(
# ion.rangeSlider also needs normalize.css, which is already included in Bootstrap.
htmlDependency(
"ionrangeslider-javascript", ionRangeSliderVersion,
"ionrangeslider-javascript", version_ion_range_slider,
src = c(href = "shared/ionrangeslider"),
script = "js/ion.rangeSlider.min.js"
),
htmlDependency(
"strftime", "0.9.2",
"strftime", version_strftime,
src = c(href = "shared/strftime"),
script = "strftime-min.js"
),
@@ -224,35 +222,19 @@ ionRangeSliderDependencyCSS <- function(theme) {
if (!is_bs_theme(theme)) {
return(htmlDependency(
"ionrangeslider-css",
ionRangeSliderVersion,
version_ion_range_slider,
src = c(href = "shared/ionrangeslider"),
stylesheet = "css/ion.rangeSlider.css"
))
}
# Remap some variable names for ionRangeSlider's scss
sass_input <- list(
list(
# The bootswatch materia theme sets $input-bg: transparent;
# which is an issue for the slider's handle(s) (#3130)
bg = "if(alpha($input-bg)==0, $body-bg, $input-bg)",
fg = sprintf(
"if(alpha($input-color)==0, $%s, $input-color)",
if ("3" %in% bslib::theme_version(theme)) "text-color" else "body-color"
),
accent = "$component-active-bg",
`font-family` = "$font-family-base"
),
sass::sass_file(
system.file(package = "shiny", "www/shared/ionrangeslider/scss/shiny.scss")
)
)
bslib::bs_dependency(
input = sass_input,
input = sass::sass_file(
system.file(package = "shiny", "www/shared/ionrangeslider/scss/shiny.scss")
),
theme = theme,
name = "ionRangeSlider",
version = ionRangeSliderVersion,
version = version_ion_range_slider,
cache_key_extra = shinyPackageVersion()
)
}

View File

@@ -112,35 +112,13 @@
#'
#' }
#' @export
insertTab <- function(inputId, tab, target,
insertTab <- function(inputId, tab, target = NULL,
position = c("before", "after"), select = FALSE,
session = getDefaultReactiveDomain()) {
force(target)
force(select)
position <- match.arg(position)
inputId <- session$ns(inputId)
# Barbara -- August 2017
# Note: until now, the number of tabs in a tabsetPanel (or navbarPage
# or navlistPanel) was always fixed. So, an easy way to give an id to
# a tab was simply incrementing a counter. (Just like it was easy to
# give a random 4-digit number to identify the tabsetPanel). Since we
# can only know this in the client side, we'll just pass `id` and
# `tsid` (TabSetID) as dummy values that will be fixed in the JS code.
item <- buildTabItem("id", "tsid", TRUE, divTag = tab,
textFilter = if (is.character(tab)) navbarMenuTextFilter else NULL)
callback <- function() {
session$sendInsertTab(
inputId = inputId,
liTag = processDeps(item$liTag, session),
divTag = processDeps(item$divTag, session),
menuName = NULL,
target = target,
position = position,
select = select)
}
session$onFlush(callback, once = TRUE)
bslib::nav_insert(
inputId, tab, target,
match.arg(position), select, session
)
}
#' @param menuName This argument should only be used when you want to
@@ -159,63 +137,21 @@ insertTab <- function(inputId, tab, target,
#' @export
prependTab <- function(inputId, tab, select = FALSE, menuName = NULL,
session = getDefaultReactiveDomain()) {
force(select)
force(menuName)
inputId <- session$ns(inputId)
item <- buildTabItem("id", "tsid", TRUE, divTag = tab,
textFilter = if (is.character(tab)) navbarMenuTextFilter else NULL)
callback <- function() {
session$sendInsertTab(
inputId = inputId,
liTag = processDeps(item$liTag, session),
divTag = processDeps(item$divTag, session),
menuName = menuName,
target = NULL,
position = "after",
select = select)
}
session$onFlush(callback, once = TRUE)
bslib::tab_prepend(inputId, tab, menu_title = menuName, select = select, session = session)
}
#' @rdname insertTab
#' @export
appendTab <- function(inputId, tab, select = FALSE, menuName = NULL,
session = getDefaultReactiveDomain()) {
force(select)
force(menuName)
inputId <- session$ns(inputId)
item <- buildTabItem("id", "tsid", TRUE, divTag = tab,
textFilter = if (is.character(tab)) navbarMenuTextFilter else NULL)
callback <- function() {
session$sendInsertTab(
inputId = inputId,
liTag = processDeps(item$liTag, session),
divTag = processDeps(item$divTag, session),
menuName = menuName,
target = NULL,
position = "before",
select = select)
}
session$onFlush(callback, once = TRUE)
bslib::tab_append(inputId, tab, menu_title = menuName, select = select, session = session)
}
#' @rdname insertTab
#' @export
removeTab <- function(inputId, target,
session = getDefaultReactiveDomain()) {
force(target)
inputId <- session$ns(inputId)
callback <- function() {
session$sendRemoveTab(
inputId = inputId,
target = target)
}
session$onFlush(callback, once = TRUE)
bslib::nav_remove(inputId, target, session)
}

View File

@@ -151,18 +151,25 @@ removeModal <- function(session = getDefaultReactiveDomain()) {
#' }
#' @export
modalDialog <- function(..., title = NULL, footer = modalButton("Dismiss"),
size = c("m", "s", "l"), easyClose = FALSE, fade = TRUE) {
size = c("m", "s", "l", "xl"), easyClose = FALSE, fade = TRUE) {
size <- match.arg(size)
cls <- if (fade) "modal fade" else "modal"
div(id = "shiny-modal", class = cls, tabindex = "-1",
`data-backdrop` = if (!easyClose) "static",
`data-keyboard` = if (!easyClose) "false",
backdrop <- if (!easyClose) "static"
keyboard <- if (!easyClose) "false"
div(
id = "shiny-modal",
class = "modal",
class = if (fade) "fade",
tabindex = "-1",
`data-backdrop` = backdrop,
`data-bs-backdrop` = backdrop,
`data-keyboard` = keyboard,
`data-bs-keyboard` = keyboard,
div(
class = "modal-dialog",
class = switch(size, s = "modal-sm", m = NULL, l = "modal-lg"),
class = switch(size, s = "modal-sm", m = NULL, l = "modal-lg", xl = "modal-xl"),
div(class = "modal-content",
if (!is.null(title)) div(class = "modal-header",
tags$h4(class = "modal-title", title)
@@ -171,14 +178,25 @@ modalDialog <- function(..., title = NULL, footer = modalButton("Dismiss"),
if (!is.null(footer)) div(class = "modal-footer", footer)
)
),
tags$script("$('#shiny-modal').modal().focus();")
tags$script(
"if (window.bootstrap) {
var modal = new bootstrap.Modal(document.getElementById('shiny-modal'));
modal.show();
} else {
$('#shiny-modal').modal().focus();
}"
)
)
}
#' @export
#' @rdname modalDialog
modalButton <- function(label, icon = NULL) {
tags$button(type = "button", class = "btn btn-default",
`data-dismiss` = "modal", validateIcon(icon), label
tags$button(
type = "button",
class = "btn btn-default",
`data-dismiss` = "modal",
`data-bs-dismiss` = "modal",
validateIcon(icon), label
)
}

View File

@@ -36,12 +36,14 @@
#' @param res Resolution of resulting plot, in pixels per inch. This value is
#' passed to [grDevices::png()]. Note that this affects the resolution of PNG
#' rendering in R; it won't change the actual ppi of the browser.
#' @param alt Alternate text for the HTML `<img>` tag
#' if it cannot be displayed or viewed (i.e., the user uses a screen reader).
#' In addition to a character string, the value may be a reactive expression
#' (or a function referencing reactive values) that returns a character string.
#' NULL or "" is not recommended because those should be limited to decorative images
#' (the default is "Plot object").
#' @param alt Alternate text for the HTML `<img>` tag if it cannot be displayed
#' or viewed (i.e., the user uses a screen reader). In addition to a character
#' string, the value may be a reactive expression (or a function referencing
#' reactive values) that returns a character string. If the value is `NA` (the
#' default), then `ggplot2::get_alt_text()` is used to extract alt text from
#' ggplot objects; for other plots, `NA` results in alt text of "Plot object".
#' `NULL` or `""` is not recommended because those should be limited to
#' decorative images.
#' @param ... Arguments to be passed through to [grDevices::png()].
#' These can be used to set the width, height, background color, etc.
#' @param env The environment in which to evaluate `expr`.
@@ -58,7 +60,7 @@
#' interactive R Markdown document.
#' @export
renderPlot <- function(expr, width = 'auto', height = 'auto', res = 72, ...,
alt = "Plot object",
alt = NA,
env = parent.frame(), quoted = FALSE,
execOnResize = FALSE, outputArgs = list()
) {
@@ -212,7 +214,7 @@ resizeSavedPlot <- function(name, session, result, width, height, alt, pixelrati
src = session$fileUrl(name, outfile, contentType = "image/png"),
width = width,
height = height,
alt = alt,
alt = result$alt,
coordmap = coordmap,
error = attr(coordmap, "error", exact = TRUE)
)
@@ -288,6 +290,7 @@ drawPlot <- function(name, session, func, width, height, alt, pixelratio, res, .
recordedPlot = grDevices::recordPlot(),
coordmap = getCoordmap(value, width*pixelratio, height*pixelratio, res*pixelratio),
pixelratio = pixelratio,
alt = if (anyNA(alt)) getAltText(value) else alt,
res = res
)
}
@@ -302,10 +305,10 @@ drawPlot <- function(name, session, func, width, height, alt, pixelratio, res, .
),
function(result) {
result$img <- dropNulls(list(
src = session$fileUrl(name, outfile, contentType='image/png'),
src = session$fileUrl(name, outfile, contentType = 'image/png'),
width = width,
height = height,
alt = alt,
alt = result$alt,
coordmap = result$coordmap,
# Get coordmap error message if present
error = attr(result$coordmap, "error", exact = TRUE)
@@ -339,6 +342,24 @@ custom_print.ggplot <- function(x) {
), class = "ggplot_build_gtable")
}
# Infer alt text description from renderPlot() value
# (currently just ggplot2 is supported)
getAltText <- function(x, default = "Plot Object") {
# Since, inside renderPlot(), custom_print.ggplot()
# overrides print.ggplot, this class indicates a ggplot()
if (!inherits(x, "ggplot_build_gtable")) {
return(default)
}
# ggplot2::get_alt_text() was added in v3.3.4
# https://github.com/tidyverse/ggplot2/pull/4482
get_alt <- getNamespace("ggplot2")$get_alt_text
if (!is.function(get_alt)) {
return(default)
}
alt <- paste(get_alt(x$build), collapse = " ")
if (nzchar(alt)) alt else default
}
# The coordmap extraction functions below return something like the examples
# below. For base graphics:
# plot(mtcars$wt, mtcars$mpg)

View File

@@ -83,7 +83,7 @@ navTabsHelper <- function(files, prefix = "") {
with(tags,
li(class=if (tolower(file) %in% c("app.r", "server.r")) "active" else "",
a(href=paste("#", gsub(".", "_", file, fixed=TRUE), "_code", sep=""),
"data-toggle"="tab", paste0(prefix, file)))
"data-toggle"="tab", "data-bs-toggle"="tab", paste0(prefix, file)))
)
})
}
@@ -92,7 +92,7 @@ navTabsDropdown <- function(files) {
if (length(files) > 0) {
with(tags,
li(role="presentation", class="dropdown",
a(class="dropdown-toggle", `data-toggle`="dropdown", href="#",
a(class="dropdown-toggle", `data-toggle`="dropdown", `data-bs-toggle`="dropdown", href="#",
role="button", `aria-haspopup`="true", `aria-expanded`="false",
"www", span(class="caret")
),

View File

@@ -0,0 +1,2 @@
# Generated by tools/updateBootstrapDatepicker.R; do not edit by hand
version_bs_date_picker <- "1.9.0"

View File

@@ -0,0 +1,2 @@
# Generated by tools/updateIonRangeSlider.R; do not edit by hand
version_ion_range_slider <- "2.3.1"

2
R/version_selectize.R Normal file
View File

@@ -0,0 +1,2 @@
# Generated by tools/updateSelectize.R; do not edit by hand
version_selectize <- "0.12.4"

2
R/version_strftime.R Normal file
View File

@@ -0,0 +1,2 @@
# Generated by tools/updateStrftime.R; do not edit by hand
version_strftime <- "0.9.2"

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,6 @@
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
font-size: 12px;
font-family: Arial, sans-serif;
}
.irs-line {
@@ -92,7 +90,6 @@
left: 0;
width: 1px;
height: 8px;
background: #000;
}
.irs-grid-pol.small {
@@ -108,7 +105,6 @@
font-size: 9px;
line-height: 9px;
padding: 0 3px;
color: #000;
}
.irs-disable-mask {
@@ -153,7 +149,7 @@
}
.irs {
font-family: Arial, sans-serif;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.irs--shiny {
@@ -167,7 +163,7 @@
.irs--shiny .irs-line {
top: 25px;
height: 8px;
background: linear-gradient(to bottom, #dedede -50%, white 150%);
background: linear-gradient(to bottom, #dedede -50%, #fff 150%);
background-color: #ededed;
border: 1px solid #cccccc;
border-radius: 8px;
@@ -176,9 +172,9 @@
.irs--shiny .irs-bar {
top: 25px;
height: 8px;
border-top: 1px solid #428bca;
border-bottom: 1px solid #428bca;
background: #428bca;
border-top: 1px solid #337ab7;
border-bottom: 1px solid #337ab7;
background: #337ab7;
}
.irs--shiny .irs-bar--single {
@@ -207,14 +203,13 @@
}
.irs--shiny .irs-handle.state_hover, .irs--shiny .irs-handle:hover {
background: white;
background: #fff;
}
.irs--shiny .irs-min,
.irs--shiny .irs-max {
top: 0;
padding: 1px 3px;
color: #333333;
text-shadow: none;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 3px;
@@ -233,7 +228,7 @@
color: #fff;
text-shadow: none;
padding: 1px 3px;
background-color: #428bca;
background-color: #337ab7;
border-radius: 3px;
font-size: 11px;
line-height: 1.333;
@@ -250,12 +245,11 @@
}
.irs--shiny .irs-grid-pol {
background-color: black;
background-color: #000;
}
.irs--shiny .irs-grid-text {
bottom: 5px;
color: #1a1a1a;
}
.irs--shiny .irs-grid-pol.small {

View File

@@ -4,8 +4,6 @@
@include pos-r();
-webkit-touch-callout: none;
@include no-click();
font-size: 12px;
font-family: Arial, sans-serif;
&-line {
@include pos-r();
@@ -83,7 +81,6 @@
left: 0;
width: 1px;
height: 8px;
background: #000;
&.small {
height: 4px;
@@ -99,7 +96,6 @@
font-size: 9px;
line-height: 9px;
padding: 0 3px;
color: #000;
}
}

View File

@@ -19,8 +19,7 @@
////////////////////////////////////////////////////////////////////////////
// Re-define font-family on .irs to make it configurable
$font-family: Arial, sans-serif !default;
$font-family: $font-family-base !default;
.irs {
font-family: $font-family;
}
@@ -36,9 +35,9 @@ $font-family: Arial, sans-serif !default;
$custom_radius: 3px !default;
// "High-level" coloring
$bg: white !default;
$fg: black !default;
$accent: #428bca !default;
$bg: $body-bg !default;
$fg: color-contrast($body-bg) !default;
$accent: $component-active-bg !default;
// "Low-level" coloring, borders, and fonts
$line_bg: linear-gradient(to bottom, mix($bg, $fg, 87%) -50%, $bg 150%) !default;
@@ -52,7 +51,7 @@ $font-family: Arial, sans-serif !default;
$handle_border: 1px solid mix($bg, $fg, 67%) !default;
$handle_box_shadow: 1px 1px 3px rgba($bg, 0.3) !default;
$minmax_text_color: mix($bg, $fg, 20%) !default;
$minmax_text_color: null !default;
$minmax_bg_color: rgba($fg, 0.1) !default;
$minmax_font_size: 10px !default;
$minmax_line_height: 1.333 !default;
@@ -64,7 +63,7 @@ $font-family: Arial, sans-serif !default;
$grid_major_color: $fg !default;
$grid_minor_color: mix($bg, $fg, 60%) !default;
$grid_text_color: mix($bg, $fg, 10%) !default;
$grid_text_color: null !default;
height: 40px;

View File

@@ -13,7 +13,7 @@ $selectize-color-text: $input-color !default;
$selectize-color-highlight: rgba(255,237,40,0.4) !default;
$selectize-color-input: $input-bg !default;
$selectize-color-input-full: $input-bg !default;
$selectize-color-input-error: theme-color("danger") !default;
$selectize-color-input-error: $danger !default;
$selectize-color-input-error-focus: darken($selectize-color-input-error, 10%) !default;
$selectize-color-disabled: $input-bg !default;
$selectize-color-item: mix($selectize-color-input, $selectize-color-text, 90%) !default;

View File

@@ -0,0 +1,3 @@
$input-line-height-sm: $form-select-line-height !default;
@import 'selectize.bootstrap4';
.selectize-control{padding:0;}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,7 @@
insertTab(
inputId,
tab,
target,
target = NULL,
position = c("before", "after"),
select = FALSE,
session = getDefaultReactiveDomain()

View File

@@ -9,7 +9,7 @@ modalDialog(
...,
title = NULL,
footer = modalButton("Dismiss"),
size = c("m", "s", "l"),
size = c("m", "s", "l", "xl"),
easyClose = FALSE,
fade = TRUE
)

View File

@@ -17,7 +17,7 @@ navbarPage(
collapsible = FALSE,
fluid = TRUE,
theme = NULL,
windowTitle = title,
windowTitle = NA,
lang = NULL
)
@@ -73,8 +73,10 @@ build of Bootstrap 3 with a customized version of Bootstrap 3 or higher.
(normally a css file within the www directory, e.g. \code{www/bootstrap.css}).
}}
\item{windowTitle}{The title that should be displayed by the browser window.
Useful if \code{title} is not a string.}
\item{windowTitle}{the browser window title (as a character string). The
default value, \code{NA}, means to use any character strings that appear in
\code{title} (if none are found, the host URL of the page is displayed by
default).}
\item{lang}{ISO 639-1 language code for the HTML page, such as "en" or "ko".
This will be used as the lang in the \code{<html>} tag, as in \code{<html lang="en">}.

View File

@@ -40,12 +40,14 @@ information on the default sizing policy.}
\item{...}{Arguments to be passed through to \code{\link[grDevices:png]{grDevices::png()}}.
These can be used to set the width, height, background color, etc.}
\item{alt}{Alternate text for the HTML \verb{<img>} tag
if it cannot be displayed or viewed (i.e., the user uses a screen reader).
In addition to a character string, the value may be a reactive expression
(or a function referencing reactive values) that returns a character string.
NULL or "" is not recommended because those should be limited to decorative images
(the default is "Plot object").}
\item{alt}{Alternate text for the HTML \verb{<img>} tag if it cannot be displayed
or viewed (i.e., the user uses a screen reader). In addition to a character
string, the value may be a reactive expression (or a function referencing
reactive values) that returns a character string. If the value is \code{NA} (the
default), then \code{ggplot2::get_alt_text()} is used to extract alt text from
ggplot objects; for other plots, \code{NA} results in alt text of "Plot object".
\code{NULL} or \code{""} is not recommended because those should be limited to
decorative images.}
\item{outputArgs}{A list of arguments to be passed through to the implicit
call to \code{\link[=plotOutput]{plotOutput()}} when \code{renderPlot} is used in an

View File

@@ -10,7 +10,7 @@ renderPlot(
height = "auto",
res = 72,
...,
alt = "Plot object",
alt = NA,
env = parent.frame(),
quoted = FALSE,
execOnResize = FALSE,
@@ -41,12 +41,14 @@ rendering in R; it won't change the actual ppi of the browser.}
\item{...}{Arguments to be passed through to \code{\link[grDevices:png]{grDevices::png()}}.
These can be used to set the width, height, background color, etc.}
\item{alt}{Alternate text for the HTML \verb{<img>} tag
if it cannot be displayed or viewed (i.e., the user uses a screen reader).
In addition to a character string, the value may be a reactive expression
(or a function referencing reactive values) that returns a character string.
NULL or "" is not recommended because those should be limited to decorative images
(the default is "Plot object").}
\item{alt}{Alternate text for the HTML \verb{<img>} tag if it cannot be displayed
or viewed (i.e., the user uses a screen reader). In addition to a character
string, the value may be a reactive expression (or a function referencing
reactive values) that returns a character string. If the value is \code{NA} (the
default), then \code{ggplot2::get_alt_text()} is used to extract alt text from
ggplot objects; for other plots, \code{NA} results in alt text of "Plot object".
\code{NULL} or \code{""} is not recommended because those should be limited to
decorative images.}
\item{env}{The environment in which to evaluate \code{expr}.}

View File

@@ -25,6 +25,8 @@ rules:
- off
"@typescript-eslint/no-explicit-any":
- off
"@typescript-eslint/explicit-module-boundary-types":
- error
camelcase:
- error
default-case:

1
srcts/.gitignore vendored
View File

@@ -7,3 +7,4 @@ node_modules/
!.yarn/versions
.pnp.*
coverage/
madge.svg

7
srcts/.madgerc Normal file
View File

@@ -0,0 +1,7 @@
{
"detectiveOptions": {
"ts": {
"skipTypeImports": true
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/bin/eslint.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/bin/eslint.js your application uses
module.exports = absRequire(`eslint/bin/eslint.js`);

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require eslint/lib/api.js
require(absPnpApiPath).setup();
}
}
// Defer to the real eslint/lib/api.js your application uses
module.exports = absRequire(`eslint/lib/api.js`);

View File

@@ -1,6 +0,0 @@
{
"name": "eslint",
"version": "7.19.0-pnpify",
"main": "./lib/api.js",
"type": "commonjs"
}

View File

@@ -1,5 +0,0 @@
# This file is automatically generated by PnPify.
# Manual changes will be lost!
integrations:
- vscode

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve, dirname} = require(`path`);
const relPnpApiPath = "../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require prettier/index.js
require(absPnpApiPath).setup();
}
const pnpifyResolution = require.resolve(`@yarnpkg/pnpify`, {paths: [dirname(absPnpApiPath)]});
if (typeof global[`__yarnpkg_sdk_is_using_pnpify__`] === `undefined`) {
Object.defineProperty(global, `__yarnpkg_sdk_is_using_pnpify__`, {configurable: true, value: true});
process.env.NODE_OPTIONS += ` -r ${pnpifyResolution}`;
// Apply PnPify to the current process
absRequire(pnpifyResolution).patchFs();
}
}
// Defer to the real prettier/index.js your application uses
module.exports = absRequire(`prettier/index.js`);

View File

@@ -1,6 +0,0 @@
{
"name": "prettier",
"version": "2.2.1-pnpify",
"main": "./index.js",
"type": "commonjs"
}

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsc
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsc your application uses
module.exports = absRequire(`typescript/bin/tsc`);

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/bin/tsserver
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/bin/tsserver your application uses
module.exports = absRequire(`typescript/bin/tsserver`);

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsc.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsc.js your application uses
module.exports = absRequire(`typescript/lib/tsc.js`);

View File

@@ -1,111 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
const moduleWrapper = tsserver => {
const {isAbsolute} = require(`path`);
const pnpApi = require(`pnpapi`);
const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => {
return `${locator.name}@${locator.reference}`;
}));
// VSCode sends the zip paths to TS using the "zip://" prefix, that TS
// doesn't understand. This layer makes sure to remove the protocol
// before forwarding it to TS, and to add it back on all returned paths.
function toEditorPath(str) {
// We add the `zip:` prefix to both `.zip/` paths and virtual paths
if (isAbsolute(str) && !str.match(/^\^zip:/) && (str.match(/\.zip\//) || str.match(/\$\$virtual\//))) {
// We also take the opportunity to turn virtual paths into physical ones;
// this makes is much easier to work with workspaces that list peer
// dependencies, since otherwise Ctrl+Click would bring us to the virtual
// file instances instead of the real ones.
//
// We only do this to modules owned by the the dependency tree roots.
// This avoids breaking the resolution when jumping inside a vendor
// with peer dep (otherwise jumping into react-dom would show resolution
// errors on react).
//
const resolved = pnpApi.resolveVirtual(str);
if (resolved) {
const locator = pnpApi.findPackageLocator(resolved);
if (locator && dependencyTreeRoots.has(`${locator.name}@${locator.reference}`)) {
str = resolved;
}
}
str = str.replace(/\\/g, `/`)
str = str.replace(/^\/?/, `/`);
// Absolute VSCode `Uri.fsPath`s need to start with a slash.
// VSCode only adds it automatically for supported schemes,
// so we have to do it manually for the `zip` scheme.
// The path needs to start with a caret otherwise VSCode doesn't handle the protocol
//
// Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910
//
if (str.match(/\.zip\//)) {
str = `${isVSCode ? `^` : ``}zip:${str}`;
}
}
return str;
}
function fromEditorPath(str) {
return process.platform === `win32`
? str.replace(/^\^?zip:\//, ``)
: str.replace(/^\^?zip:/, ``);
}
// And here is the point where we hijack the VSCode <-> TS communications
// by adding ourselves in the middle. We locate everything that looks
// like an absolute path of ours and normalize it.
const Session = tsserver.server.Session;
const {onMessage: originalOnMessage, send: originalSend} = Session.prototype;
let isVSCode = false;
return Object.assign(Session.prototype, {
onMessage(/** @type {string} */ message) {
const parsedMessage = JSON.parse(message)
if (
parsedMessage != null &&
typeof parsedMessage === `object` &&
parsedMessage.arguments &&
parsedMessage.arguments.hostInfo === `vscode`
) {
isVSCode = true;
}
return originalOnMessage.call(this, JSON.stringify(parsedMessage, (key, value) => {
return typeof value === `string` ? fromEditorPath(value) : value;
}));
},
send(/** @type {any} */ msg) {
return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => {
return typeof value === `string` ? toEditorPath(value) : value;
})));
}
});
};
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/tsserver.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/tsserver.js your application uses
module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`));

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env node
const {existsSync} = require(`fs`);
const {createRequire, createRequireFromPath} = require(`module`);
const {resolve} = require(`path`);
const relPnpApiPath = "../../../../.pnp.js";
const absPnpApiPath = resolve(__dirname, relPnpApiPath);
const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath);
if (existsSync(absPnpApiPath)) {
if (!process.versions.pnp) {
// Setup the environment to be able to require typescript/lib/typescript.js
require(absPnpApiPath).setup();
}
}
// Defer to the real typescript/lib/typescript.js your application uses
module.exports = absRequire(`typescript/lib/typescript.js`);

View File

@@ -1,6 +0,0 @@
{
"name": "typescript",
"version": "4.1.5-pnpify",
"main": "./lib/typescript.js",
"type": "commonjs"
}

View File

@@ -3,5 +3,7 @@ nodeLinker: node-modules
plugins:
- path: .yarn/plugins/@yarnpkg/plugin-outdated.cjs
spec: "https://github.com/mskelton/yarn-plugin-outdated/raw/main/bundles/@yarnpkg/plugin-outdated.js"
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
yarnPath: .yarn/releases/yarn-2.4.0.cjs

View File

@@ -36,11 +36,54 @@
* √ Verify it works on phantomjs / shinytest
* √ Set up initial jest tests
* √ Use a global shim to avoid importing jquery directly, but make testing easy to test
* Update the /tools/update*.R scripts to produce a version and install node dependencies
* √ jquery
* √ ion range slider
* √ selectize
* √ strftime
* √ bootstrap date picker
* font awesome?
* bootstrap accessibility plugin?
# Round #2
* Convert registered bindings
* √ Input bindings
* √ Output bindings
* Add default value to `subscribe(callback)` callback function of `false`. B/c if the value was not provided, it was not truthy, therefore equivalent to `false`.
* √ radio
* √ checkboxgroup
* √ daterange
* √ actionbutton
* √ bootstraptabinput
* √ snake_case to camelCase conversions.
* √ globally import strftime from `window.strftime`
* Remove `evt` from jQuery.on callbacks where `evt` was not used.
* √ checkbox.subscribe
* √ checkboxgroup.subscribe
* √ radio.subscribe
* √ slider.subscribe
* √ date.subscribe
* √ selectInput.subscribe
* √ actionButton.subscribe
* √ bootstraptabinput.subscribe
* Convert usage of `+x` to `Number(x)`
* https://stackoverflow.com/a/15872631/591574
* √ slider.getValue()
* √ number.getValue()
* √ Adjust tabinput.ts `setValue()` to return either `false | void`, not `false | true`.
* What matters is that `false` is returned, or nothing is returned. Replaced `return true;` with `return;`
* Questions
* Why does `receiveMessage(data)` sometimes have a `label`?
* Should we have a update datatables script?
# Later TODO
* Each _file_ will be pulled out as possible into smaller files in separate PRs
* Convert `FileProcessor` to a true class definition
* Use --strictNullChecks in tsconfig.json
* Make `_*()` methods `private *()`
* √ Each _file_ will be pulled out as possible into smaller files in separate PRs
* √ Convert `FileProcessor` to a true class definition
* Break up `./utils` into many files
* Remove any `: any` types
* Make `@typescript-eslint/explicit-module-boundary-types` an error
@@ -49,6 +92,11 @@
* Completely remove `parcel` from `./package.json` and only use `esbuild`
* Delete 'shiny-es5' files
* Delete 'old' folder
* _Uglify_ js files (like in previous Gruntfile.js)
* datepicker
* ionrangeslider
* selectize
# Eventual TODO

View File

@@ -5,7 +5,7 @@
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": "3.9"
"corejs": "3.12"
}
]
],

View File

@@ -4,37 +4,64 @@ import readcontrol from "readcontrol";
import process from "process";
import globalsPlugin from "esbuild-plugin-globals";
let watch = process.argv.length >= 3 && process.argv[2] == "--watch";
async function buildFile(
fileName,
extraOpts = {},
strSize = "shiny.min.js".length
) {
let watch = process.argv.length >= 3 && process.argv[2] == "--watch";
let incremental = false;
let outdir = "../inst/www/shared/";
let opts = {
entryPoints: ["src/index.ts"],
bundle: true,
watch: watch,
plugins: [
globalsPlugin({
jquery: "window.jQuery",
}),
babel(),
],
target: "es5",
sourcemap: true,
define: {
"process.env.SHINY_VERSION": `"${
readcontrol.readSync("../DESCRIPTION").version
}"`,
},
};
let printName = fileName;
console.log("Building shiny.js");
await esbuild.build({
...opts,
outfile: outdir + "shiny.js",
});
while (printName.length < strSize) {
printName = printName + " ";
}
console.log("Building shiny.min.js");
await esbuild.build({
...opts,
outfile: outdir + "shiny.min.js",
minify: true,
});
const onRebuild = function (error, result) {
if (error) {
console.error(printName, "watch build failed:\n", error);
} else {
console.log("√ -", printName, "-", new Date().toJSON());
}
return;
};
if (watch) {
incremental = true;
watch = {
onRebuild: onRebuild,
};
}
const outdir = "../inst/www/shared/";
console.log("Building " + fileName);
await esbuild.build({
outfile: outdir + fileName,
entryPoints: ["src/index.ts"],
bundle: true,
incremental: incremental,
watch: watch,
plugins: [
globalsPlugin({
jquery: "window.jQuery",
//// Loaded dynamically. MUST use `window.strftime` within code
// strftime: "window.strftime",
}),
babel(),
],
target: "es5",
sourcemap: true,
define: {
"process.env.SHINY_VERSION": `"${
readcontrol.readSync("../DESCRIPTION").version
}"`,
},
...extraOpts,
});
onRebuild();
}
buildFile("shiny.js");
buildFile("shiny.min.js", { minify: true });

View File

@@ -1,4 +1,4 @@
import {readdirSync, unlinkSync, writeFileSync} from "fs";
import { readdirSync, unlinkSync, writeFileSync } from "fs";
import esbuild from "esbuild";
import globalsPlugin from "esbuild-plugin-globals";
@@ -6,7 +6,6 @@ import globalsPlugin from "esbuild-plugin-globals";
// let watch = process.argv.length >= 3 && process.argv[2] == "--watch";
let instdir = "../inst/";
let outdir = instdir + "/www/shared/";
let opts = {
bundle: false,
@@ -16,27 +15,32 @@ let opts = {
};
console.log("Building datepicker");
const locale_files = readdirSync(instdir + "www/shared/datepicker/js/locales/")
const localeFiles = readdirSync(instdir + "www/shared/datepicker/js/locales/");
let require_files = locale_files.map(function(filename) {
return `require("./locales/${ filename }");`;
}).join("\n");
let requireFiles = localeFiles
.map(function (filename) {
return `require("./locales/${filename}");`;
})
.join("\n");
let tmpfile = instdir + "www/shared/datepicker/js/temp.js";
writeFileSync(tmpfile,
`require("./bootstrap-datepicker.js");
${require_files}`)
writeFileSync(
tmpfile,
`require("./bootstrap-datepicker.js");
${requireFiles}`
);
await esbuild.build({
...opts,
plugins:[
plugins: [
globalsPlugin({
jquery: "window.jQuery",
})
}),
],
bundle: true,
entryPoints: [tmpfile],
outfile: instdir + "www/shared/datepicker/js/bootstrap-datepicker.min.js",
external: ['jquery'],
external: ["jquery"],
minify: true,
});
// Clean up
@@ -45,9 +49,7 @@ unlinkSync(tmpfile);
console.log("Building ionrangeslider");
await esbuild.build({
...opts,
entryPoints: [
instdir + "www/shared/ionrangeslider/js/ion.rangeSlider.js"
],
entryPoints: [instdir + "www/shared/ionrangeslider/js/ion.rangeSlider.js"],
outfile: instdir + "www/shared/ionrangeslider/js/ion.rangeSlider.min.js",
minify: true,
});
@@ -56,8 +58,10 @@ console.log("Building selectize");
await esbuild.build({
...opts,
entryPoints: [
instdir + "www/shared/selectize/accessibility/js/selectize-plugin-a11y.js"
instdir + "www/shared/selectize/accessibility/js/selectize-plugin-a11y.js",
],
outfile: instdir + "www/shared/selectize/accessibility/js/selectize-plugin-a11y.min.js",
outfile:
instdir +
"www/shared/selectize/accessibility/js/selectize-plugin-a11y.min.js",
minify: true,
});

View File

@@ -1,66 +1,81 @@
{
"name": "@types/shiny",
"private": true,
"main": "src/index.ts",
"version": "1.6.0",
"main": "",
"types": "src_d/shiny/index.d.ts",
"engines": {
"node": ">= 14",
"yarn": ">= 1.22"
},
"devDependencies": {
"@babel/core": "^7.12.17",
"@babel/preset-env": "^7.12.17",
"@babel/preset-typescript": "^7.12.17",
"@babel/runtime": "^7.12.18",
"@testing-library/dom": "^7.29.6",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/user-event": "^12.7.3",
"@types/jest": "^26",
"@types/jquery": "patch:@types/jquery@^3.5.5#./patch/types-jquery.patch",
"@types/lodash": "^4",
"@types/node": "^14.14.31",
"@typescript-eslint/eslint-plugin": "^4",
"@typescript-eslint/parser": "^4",
"browserslist": "^4.16.3",
"esbuild": "^0.8.50",
"@babel/core": "^7.14.3",
"@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/preset-env": "^7.14.2",
"@babel/preset-typescript": "^7.13.0",
"@babel/runtime": "^7.14.0",
"@testing-library/dom": "^7.31.0",
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/user-event": "^13.1.9",
"@types/bootstrap": "3.4.0",
"@types/bootstrap-datepicker": "0.0.14",
"@types/datatables.net": "^1.10.19",
"@types/ion-rangeslider": "2.3.0",
"@types/jest": "^26.0.23",
"@types/jquery": "^3.5.5",
"@types/lodash": "^4.14.170",
"@types/node": "^15.6.1",
"@types/selectize": "0.12.34",
"@typescript-eslint/eslint-plugin": "^4.25.0",
"@typescript-eslint/parser": "^4.25.0",
"bootstrap-datepicker": "1.9.0",
"browserslist": "^4.16.6",
"esbuild": "^0.12.4",
"esbuild-plugin-babel": "0.2.3",
"esbuild-plugin-globals": "^0.1.1",
"eslint": "^7",
"eslint-config-prettier": "^7",
"eslint-plugin-jest": "^24",
"eslint-plugin-jest-dom": "^3.6.5",
"eslint-plugin-prettier": "^3",
"jest": "^26",
"eslint": "^7.27.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-jest": "^24.3.6",
"eslint-plugin-jest-dom": "^3.9.0",
"eslint-plugin-prettier": "^3.4.0",
"ion-rangeslider": "2.3.1",
"jest": "^26.6.3",
"jquery": "3.6.0",
"parcel": "^1.12.3",
"parcel-bundler": "^1.12.3",
"madge": "^4.0.2",
"node-gyp": "^8.1.0",
"parcel": "^1.12.4",
"parcel-bundler": "^1.12.5",
"phantomjs-prebuilt": "^2.1.16",
"prettier": "2",
"prettier": "2.3.0",
"readcontrol": "^1.0.0",
"replace": "^1.2.0",
"replace": "^1.2.1",
"selectize": "0.12.4",
"strftime": "0.9.2",
"ts-jest": "^26",
"type-coverage": "^2",
"typescript": "^4"
"type-coverage": "^2.17.5",
"typescript": "~4.1.5"
},
"scripts": {
"watch": "yarn run build_shiny --watch",
"build": "yarn run build_shiny && yarn run bundle_external_libs",
"setup_build_shiny": "yarn run lint && yarn run typescript-check",
"build_shiny": "yarn run setup_build_shiny && yarn run bundle_shiny",
"build_shiny": "yarn run checks && yarn run bundle_shiny",
"bundle_shiny": "node esbuild.config.mjs",
"circular_dep_image": "madge --circular --extensions ts --image madge.svg src",
"bundle_external_libs": "node esbuild.external_libs.mjs",
"bundle_shiny_parcel2": "parcel build -d ../inst/www/shared --no-minify -o shiny.js src/index.ts",
"watch_parcel2": "yarn run setup_build_shiny && parcel run -d ../inst/www/shared -o shiny.js srcjs/index.ts",
"watch_parcel2": "yarn run checks && parcel run -d ../inst/www/shared -o shiny.js srcjs/index.ts",
"replace_shiny_version2": "replace --silent '\"[^\"]+\"; // @VERSION@' \"\\\"`node -e 'console.log(require(\"readcontrol\").readSync(\"../DESCRIPTION\").version)'`\\\"; // @VERSION@\" src/shiny.ts",
"test": "jest --coverage",
"test_phantom": "echo '\n\t!! Must manually stop phantomjs test !!\n\n' && yarn bundle_shiny && phantomjs --debug=yes ../inst/www/shared/shiny.js",
"lint": "eslint --fix --ext .ts src",
"lint-check": "eslint --ext .ts src",
"typescript-check": "tsc -p tsconfig.json --noEmit",
"typescript-check": "tsc -p tsconfig.json",
"type-check": "type-coverage -p tsconfig.json --detail --at-least 85",
"checks": "yarn run typescript-check && yarn run type-check && yarn run lint-check"
"import-check": "madge --circular --extensions ts src",
"checks": "yarn run lint && yarn run typescript-check && yarn run type-check && yarn run import-check"
},
"dependencies": {
"core-js": "^3.9",
"lodash": "^4",
"core-js": "^3.13.0",
"lodash": "^4.17.21",
"util-inspect": "https://github.com/deecewan/browser-util-inspect#c0b4350df4378ffd743e8c36dd3898ce3992823e"
}
}

View File

@@ -0,0 +1,4 @@
import { InputBinding } from "./input";
import { OutputBinding } from "./output";
export { InputBinding, OutputBinding };

View File

@@ -0,0 +1,105 @@
import { RatePolicyModes } from "../../inputPolicies/inputRateDecorator";
import { bindScope } from "../../shiny/bind";
class InputBinding {
name: string;
// Returns a jQuery object or element array that contains the
// descendants of scope that match this binding
find(scope: bindScope): JQuery<HTMLElement> {
throw "Not implemented";
// add so that typescript isn't mad about an unused var
scope;
}
getId(el: HTMLElement): string {
return el["data-input-id"] || el.id;
}
// Gives the input a type in case the server needs to know it
// to deserialize the JSON correctly
getType(el: HTMLElement): string | false {
return false;
el;
}
getValue(el: HTMLElement): unknown {
throw "Not implemented";
el; // unused var
}
// The callback method takes one argument, whose value is boolean. If true,
// allow deferred (debounce or throttle) sending depending on the value of
// getRatePolicy. If false, send value immediately. Default behavior is `false`
subscribe(el: HTMLElement, callback: (value: boolean) => void): void {
// empty
el;
callback;
}
unsubscribe(el: HTMLElement): void {
// empty
el;
}
// This is used for receiving messages that tell the input object to do
// things, such as setting values (including min, max, and others).
// 'data' should be an object with elements corresponding to value, min,
// max, etc., as appropriate for the type of input object. It also should
// trigger a change event.
receiveMessage(el: HTMLElement, data: unknown): void {
throw "Not implemented";
el;
data;
}
getState(el: HTMLElement): unknown {
throw "Not implemented";
el;
}
getRatePolicy(
el: HTMLElement
): { policy: RatePolicyModes; delay: number } | null {
return null;
el;
}
// Some input objects need initialization before being bound. This is
// called when the document is ready (for statically-added input objects),
// and when new input objects are added to the document with
// htmlOutputBinding.renderValue() (for dynamically-added input objects).
// This is called before the input is bound.
initialize(el: HTMLElement): void {
//empty
el;
}
// This is called after unbinding the output.
dispose(el: HTMLElement): void {
//empty
el;
}
}
//// NOTES FOR FUTURE DEV
// Turn register systemin into something that is intialized for every instance.
// "Have a new instance for every item, not an instance that does work on every item"
//
// * Keep register as is for historical purposes
// make a new register function that would take a class
// these class could be constructed at build time
// store the constructed obj on the ele and retrieve
// Then the classes could store their information within their local class, rather than on the element
// VERY CLEAN!!!
// to invoke methods, it would be something like `el.shinyClass.METHOD(x,y,z)`
// * See https://github.com/rstudio/shinyvalidate/blob/c8becd99c01fac1bac03b50e2140f49fca39e7f4/srcjs/shinyvalidate.js#L157-L167
// these methods would be added using a new method like `inputBindings.registerClass(ClassObj, name)`
// things to watch out for:
// * unbind, then rebind. Maybe we stash the local content.
// Updates:
// * Feel free to alter method names on classes. (And make them private)
//// END NOTES FOR FUTURE DEV
export { InputBinding };

View File

@@ -0,0 +1,81 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import { hasOwnProperty } from "../../utils";
type ActionButtonReceiveMessageData = { label?: string; icon?: string };
class ActionButtonInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".action-button");
}
getValue(el: HTMLElement): number {
return $(el).data("val") || 0;
}
setValue(el: HTMLElement, value: number): void {
$(el).data("val", value);
}
getType(el: HTMLElement): string {
return "shiny.action";
el;
}
subscribe(el: HTMLElement, callback: (x: boolean) => void): void {
$(el).on(
"click.actionButtonInputBinding",
// e: Event
function () {
const $el = $(this);
const val = $el.data("val") || 0;
$el.data("val", val + 1);
callback(false);
}
);
}
getState(el: HTMLElement): { value: number } {
return { value: this.getValue(el) };
}
receiveMessage(el: HTMLElement, data: ActionButtonReceiveMessageData): void {
const $el = $(el);
// retrieve current label and icon
let label = $el.text();
let icon = "";
// to check (and store) the previous icon, we look for a $el child
// object that has an i tag, and some (any) class (this prevents
// italicized text - which has an i tag but, usually, no class -
// from being mistakenly selected)
if ($el.find("i[class]").length > 0) {
const iconHtml = $el.find("i[class]")[0];
if (iconHtml === $el.children()[0]) {
// another check for robustness
icon = $(iconHtml).prop("outerHTML");
}
}
// update the requested properties
if (hasOwnProperty(data, "label")) label = data.label;
if (hasOwnProperty(data, "icon")) {
icon = data.icon;
// if the user entered icon=character(0), remove the icon
if (icon.length === 0) icon = "";
}
// produce new html
$el.html(icon + " " + label);
}
unsubscribe(el: HTMLElement): void {
$(el).off(".actionButtonInputBinding");
}
}
// TODO-barret should this be put in the init methods?
$(document).on("click", "a.action-button", function (e) {
e.preventDefault();
});
export { ActionButtonInputBinding };
export type { ActionButtonReceiveMessageData };

View File

@@ -0,0 +1,50 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import { hasOwnProperty } from "../../utils";
type CheckedHTMLElement = HTMLInputElement;
type CheckboxChecked = CheckedHTMLElement["checked"];
type CheckboxReceiveMessageData = { value?: CheckboxChecked; label?: string };
class CheckboxInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find('input[type="checkbox"]');
}
getValue(el: CheckedHTMLElement): CheckboxChecked {
return el.checked;
}
setValue(el: CheckedHTMLElement, value: CheckboxChecked): void {
el.checked = value;
}
subscribe(el: HTMLElement, callback: (x: boolean) => void): void {
$(el).on("change.checkboxInputBinding", function () {
callback(true);
});
}
unsubscribe(el: HTMLElement): void {
$(el).off(".checkboxInputBinding");
}
getState(el: CheckedHTMLElement): { label: string; value: CheckboxChecked } {
return {
label: $(el).parent().find("span").text(),
value: el.checked,
};
}
receiveMessage(
el: CheckedHTMLElement,
data: CheckboxReceiveMessageData
): void {
if (hasOwnProperty(data, "value")) el.checked = data.value;
// checkboxInput()'s label works different from other
// input labels...the label container should always exist
if (hasOwnProperty(data, "label"))
$(el).parent().find("span").text(data.label);
$(el).trigger("change");
}
}
export { CheckboxInputBinding };
export type { CheckedHTMLElement, CheckboxReceiveMessageData };

View File

@@ -0,0 +1,143 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import { $escape, hasOwnProperty, updateLabel } from "../../utils";
import { CheckedHTMLElement } from "./checkbox";
type CheckboxGroupHTMLElement = CheckedHTMLElement;
type ValueLabelObject = {
value: HTMLInputElement["value"];
label: string;
};
type CheckboxGroupReceiveMessageData = {
options?: string;
value?: Parameters<CheckboxGroupInputBinding["setValue"]>[1];
label: string;
};
type CheckboxGroupValue = CheckboxGroupHTMLElement["value"];
class CheckboxGroupInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-input-checkboxgroup");
}
getValue(el: CheckboxGroupHTMLElement): Array<CheckboxGroupValue> {
// Select the checkbox objects that have name equal to the grouping div's id
const $objs = $('input:checkbox[name="' + $escape(el.id) + '"]:checked');
const values = new Array($objs.length);
for (let i = 0; i < $objs.length; i++) {
values[i] = ($objs[i] as CheckboxGroupHTMLElement).value;
}
return values;
}
setValue(el: HTMLElement, value: Array<string> | string): void {
// Clear all checkboxes
$('input:checkbox[name="' + $escape(el.id) + '"]').prop("checked", false);
// Accept array
if (value instanceof Array) {
for (let i = 0; i < value.length; i++) {
$(
'input:checkbox[name="' +
$escape(el.id) +
'"][value="' +
$escape(value[i]) +
'"]'
).prop("checked", true);
}
// Else assume it's a single value
} else {
$(
'input:checkbox[name="' +
$escape(el.id) +
'"][value="' +
$escape(value) +
'"]'
).prop("checked", true);
}
}
getState(el: CheckboxGroupHTMLElement): {
label: string;
value: ReturnType<CheckboxGroupInputBinding["getValue"]>;
options: Array<ValueLabelObject>;
} {
const $objs = $(
'input:checkbox[name="' + $escape(el.id) + '"]'
) as JQuery<CheckboxGroupHTMLElement>;
// Store options in an array of objects, each with with value and label
const options = new Array($objs.length);
for (let i = 0; i < options.length; i++) {
options[i] = { value: $objs[i].value, label: this._getLabel($objs[i]) };
}
return {
label: this._getLabelNode(el).text(),
value: this.getValue(el),
options: options,
};
}
receiveMessage(
el: CheckboxGroupHTMLElement,
data: CheckboxGroupReceiveMessageData
): void {
const $el = $(el);
// This will replace all the options
if (hasOwnProperty(data, "options")) {
// Clear existing options and add each new one
$el.find("div.shiny-options-group").remove();
// Backward compatibility: for HTML generated by shinybootstrap2 package
$el.find("label.checkbox").remove();
$el.append(data.options);
}
if (hasOwnProperty(data, "value")) this.setValue(el, data.value);
updateLabel(data.label, this._getLabelNode(el));
$(el).trigger("change");
}
subscribe(
el: CheckboxGroupHTMLElement,
callback: (x: boolean) => void
): void {
$(el).on("change.checkboxGroupInputBinding", function () {
callback(false);
});
}
unsubscribe(el: CheckboxGroupHTMLElement): void {
$(el).off(".checkboxGroupInputBinding");
}
// Get the DOM element that contains the top-level label
_getLabelNode(el: CheckboxGroupHTMLElement): JQuery<HTMLElement> {
return $(el).find('label[for="' + $escape(el.id) + '"]');
}
// Given an input DOM object, get the associated label. Handles labels
// that wrap the input as well as labels associated with 'for' attribute.
_getLabel(obj: HTMLElement): string | null {
// If <label><input /><span>label text</span></label>
if ((obj.parentNode as HTMLElement).tagName === "LABEL") {
return $(obj.parentNode).find("span").text().trim();
}
return null;
}
// Given an input DOM object, set the associated label. Handles labels
// that wrap the input as well as labels associated with 'for' attribute.
_setLabel(obj: HTMLElement, value: string): null {
// If <label><input /><span>label text</span></label>
if ((obj.parentNode as HTMLElement).tagName === "LABEL") {
$(obj.parentNode).find("span").text(value);
}
return null;
}
}
export { CheckboxGroupInputBinding };
export type { CheckboxGroupReceiveMessageData };

View File

@@ -0,0 +1,317 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import {
formatDateUTC,
updateLabel,
$escape,
parseDate,
hasOwnProperty,
} from "../../utils";
declare global {
interface JQuery {
// Adjustment of https://github.com/DefinitelyTyped/DefinitelyTyped/blob/1626e0bac175121ec2e9f766a770e03a91843c31/types/bootstrap-datepicker/index.d.ts#L113-L114
bsDatepicker(methodName: "getUTCDate"): Date;
// Infinity is not allowed as a literal return type. Using `1e9999` as a placeholder that resolves to Infinity
// https://github.com/microsoft/TypeScript/issues/32277
bsDatepicker(methodName: "getStartDate"): Date | -1e9999;
bsDatepicker(methodName: "getEndDate"): Date | 1e9999;
bsDatepicker(methodName: string): void;
bsDatepicker(methodName: string, params: null | Date): void;
}
}
type DateReceiveMessageData = {
label: string;
min?: Date | null;
max?: Date | null;
value?: Date | null;
};
class DateInputBindingBase extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-date-input");
}
getType(el: HTMLElement): string {
return "shiny.date";
el;
}
subscribe(el: HTMLElement, callback: (x: boolean) => void): void {
$(el).on(
"keyup.dateInputBinding input.dateInputBinding",
// event: Event
function () {
// Use normal debouncing policy when typing
callback(true);
}
);
$(el).on(
"changeDate.dateInputBinding change.dateInputBinding",
// event: Event
function () {
// Send immediately when clicked
callback(false);
}
);
}
unsubscribe(el: HTMLElement): void {
$(el).off(".dateInputBinding");
}
getRatePolicy(): { policy: "debounce"; delay: 250 } {
return {
policy: "debounce",
delay: 250,
};
}
setValue(el: HTMLElement, data: unknown): void {
throw "not implemented";
el;
data;
}
initialize(el: HTMLElement): void {
const $input = $(el).find("input");
// The challenge with dates is that we want them to be at 00:00 in UTC so
// that we can do comparisons with them. However, the Date object itself
// does not carry timezone information, so we should call _floorDateTime()
// on Dates as soon as possible so that we know we're always working with
// consistent objects.
let date = $input.data("initial-date");
// If initial_date is null, set to current date
if (date === undefined || date === null) {
// Get local date, but normalized to beginning of day in UTC.
date = this._floorDateTime(this._dateAsUTC(new Date()));
}
this.setValue(el, date);
// Set the start and end dates, from min-date and max-date. These always
// use yyyy-mm-dd format, instead of bootstrap-datepicker's built-in
// support for date-startdate and data-enddate, which use the current
// date format.
if ($input.data("min-date") !== undefined) {
this._setMin($input[0], $input.data("min-date"));
}
if ($input.data("max-date") !== undefined) {
this._setMax($input[0], $input.data("max-date"));
}
}
_getLabelNode(el: HTMLElement): JQuery<HTMLElement> {
return $(el).find('label[for="' + $escape(el.id) + '"]');
}
// Given a format object from a date picker, return a string
_formatToString(format: {
parts: Array<string>;
separators: Array<string>;
}): string {
// Format object has structure like:
// { parts: ['mm', 'dd', 'yy'], separators: ['', '/', '/' ,''] }
let str = "";
let i;
for (i = 0; i < format.parts.length; i++) {
str += format.separators[i] + format.parts[i];
}
str += format.separators[i];
return str;
}
// Given an unambiguous date string or a Date object, set the min (start) date.
// null will unset. undefined will result in no change,
_setMin(el: HTMLElement, date: Date | undefined | null): void {
if (date === undefined) return;
if (date === null) {
$(el).bsDatepicker("setStartDate", null);
return;
}
const parsedDate = this._newDate(date);
// If date parsing fails, do nothing
if (parsedDate === null) return;
// (Assign back to date as a Date object)
date = parsedDate as Date;
if (isNaN(date.valueOf())) return;
// Workarounds for
// https://github.com/rstudio/shiny/issues/2335
const curValue = $(el).bsDatepicker("getUTCDate");
// Note that there's no `setUTCStartDate`, so we need to convert this Date.
// It starts at 00:00 UTC, and we convert it to 00:00 in local time, which
// is what's needed for `setStartDate`.
$(el).bsDatepicker("setStartDate", this._UTCDateAsLocal(date));
// If the new min is greater than the current date, unset the current date.
if (date && curValue && date.getTime() > curValue.getTime()) {
$(el).bsDatepicker("clearDates");
} else {
// Setting the date needs to be done AFTER `setStartDate`, because the
// datepicker has a bug where calling `setStartDate` will clear the date
// internally (even though it will still be visible in the UI) when a
// 2-digit year format is used.
// https://github.com/eternicode/bootstrap-datepicker/issues/2010
$(el).bsDatepicker("setUTCDate", curValue);
}
}
// Given an unambiguous date string or a Date object, set the max (end) date
// null will unset.
_setMax(el: HTMLElement, date: Date): void {
if (date === undefined) return;
if (date === null) {
$(el).bsDatepicker("setEndDate", null);
return;
}
const parsedDate = this._newDate(date);
// If date parsing fails, do nothing
if (parsedDate === null) return;
date = parsedDate as Date;
if (isNaN(date.valueOf())) return;
// Workaround for same issue as in _setMin.
const curValue = $(el).bsDatepicker("getUTCDate");
$(el).bsDatepicker("setEndDate", this._UTCDateAsLocal(date));
// If the new min is greater than the current date, unset the current date.
if (date && curValue && date.getTime() < curValue.getTime()) {
$(el).bsDatepicker("clearDates");
} else {
$(el).bsDatepicker("setUTCDate", curValue);
}
}
// Given a date string of format yyyy-mm-dd, return a Date object with
// that date at 12AM UTC.
// If date is a Date object, return it unchanged.
_newDate(date: Date | string | never): Date | null {
if (date instanceof Date) return date;
if (!date) return null;
// Get Date object - this will be at 12AM in UTC, but may print
// differently at the Javascript console.
const d = parseDate(date);
// If invalid date, return null
if (isNaN(d.valueOf())) return null;
return d;
}
// A Date can have any time during a day; this will return a new Date object
// set to 00:00 in UTC.
_floorDateTime(date: Date): Date {
date = new Date(date.getTime());
date.setUTCHours(0, 0, 0, 0);
return date;
}
// Given a Date object, return a Date object which has the same "clock time"
// in UTC. For example, if input date is 2013-02-01 23:00:00 GMT-0600 (CST),
// output will be 2013-02-01 23:00:00 UTC. Note that the JS console may
// print this in local time, as "Sat Feb 02 2013 05:00:00 GMT-0600 (CST)".
_dateAsUTC(date: Date): Date {
return new Date(date.getTime() - date.getTimezoneOffset() * 60000);
}
// The inverse of _dateAsUTC. This is needed to adjust time zones because
// some bootstrap-datepicker methods only take local dates as input, and not
// UTC.
_UTCDateAsLocal(date: Date): Date {
return new Date(date.getTime() + date.getTimezoneOffset() * 60000);
}
}
class DateInputBinding extends DateInputBindingBase {
// Return the date in an unambiguous format, yyyy-mm-dd (as opposed to a
// format like mm/dd/yyyy)
getValue(el: HTMLElement): string {
const date = $(el).find("input").bsDatepicker("getUTCDate");
return formatDateUTC(date);
}
// value must be an unambiguous string like '2001-01-01', or a Date object.
setValue(el: HTMLElement, value: Date): void {
// R's NA, which is null here will remove current value
if (value === null) {
$(el).find("input").val("").bsDatepicker("update");
return;
}
const date = this._newDate(value);
if (date === null) {
return;
}
// If date is invalid, do nothing
if (isNaN((date as Date).valueOf())) return;
$(el).find("input").bsDatepicker("setUTCDate", date);
}
getState(el: HTMLElement): {
label: string;
value: string | null;
valueString: string | number | string[];
min: string | null;
max: string | null;
language: string | null;
weekstart: number;
format: string;
startview: DatepickerViewModes;
} {
const $el = $(el);
const $input = $el.find("input");
let min = $input.data("datepicker").startDate;
let max = $input.data("datepicker").endDate;
// Stringify min and max. If min and max aren't set, they will be
// -Infinity and Infinity; replace these with null.
min = min === -Infinity ? null : formatDateUTC(min);
max = max === Infinity ? null : formatDateUTC(max);
// startViewMode is stored as a number; convert to string
let startview = $input.data("datepicker").startViewMode;
if (startview === 2) startview = "decade";
else if (startview === 1) startview = "year";
else if (startview === 0) startview = "month";
return {
label: this._getLabelNode(el).text(),
value: this.getValue(el),
valueString: $input.val(),
min: min,
max: max,
language: $input.data("datepicker").language,
weekstart: $input.data("datepicker").weekStart,
format: this._formatToString($input.data("datepicker").format),
startview: startview,
};
}
receiveMessage(el: HTMLElement, data: DateReceiveMessageData): void {
const $input = $(el).find("input");
updateLabel(data.label, this._getLabelNode(el));
if (hasOwnProperty(data, "min")) this._setMin($input[0], data.min);
if (hasOwnProperty(data, "max")) this._setMax($input[0], data.max);
// Must set value only after min and max have been set. If new value is
// outside the bounds of the previous min/max, then the result will be a
// blank input.
if (hasOwnProperty(data, "value")) this.setValue(el, data.value);
$(el).trigger("change");
}
}
export { DateInputBinding, DateInputBindingBase };
export type { DateReceiveMessageData };

View File

@@ -0,0 +1,185 @@
import $ from "jquery";
import {
$escape,
formatDateUTC,
hasOwnProperty,
updateLabel,
} from "../../utils";
import { DateInputBindingBase } from "./date";
type DateRangeReceiveMessageData = {
label: string;
min?: Date;
max?: Date;
value?: { start?: Date; end?: Date };
};
class DateRangeInputBinding extends DateInputBindingBase {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-date-range-input");
}
// Return the date in an unambiguous format, yyyy-mm-dd (as opposed to a
// format like mm/dd/yyyy)
getValue(el: HTMLElement): [string, string] {
const $inputs = $(el).find("input");
const start = $inputs.eq(0).bsDatepicker("getUTCDate");
const end = $inputs.eq(1).bsDatepicker("getUTCDate");
return [formatDateUTC(start), formatDateUTC(end)];
}
// value must be an object, with optional fields `start` and `end`. These
// should be unambiguous strings like '2001-01-01', or Date objects.
setValue(el: HTMLElement, value: { start?: Date; end?: Date }): void {
if (!(value instanceof Object)) {
return;
}
// Get the start and end input objects
const $inputs = $(el).find("input");
// If value is undefined, don't try to set
// null will remove the current value
if (value.start !== undefined) {
if (value.start === null) {
$inputs.eq(0).val("").bsDatepicker("update");
} else {
const start = this._newDate(value.start);
$inputs.eq(0).bsDatepicker("setUTCDate", start);
}
}
if (value.end !== undefined) {
if (value.end === null) {
$inputs.eq(1).val("").bsDatepicker("update");
} else {
const end = this._newDate(value.end);
$inputs.eq(1).bsDatepicker("setUTCDate", end);
}
}
}
getState(el: HTMLElement): {
label: string;
value: [string, string];
valueString: [string, string];
min: ReturnType<typeof formatDateUTC> | null;
max: ReturnType<typeof formatDateUTC> | null;
weekstart: string;
format: string;
language: string;
startview: string;
} {
const $el = $(el);
const $inputs = $el.find("input");
const $startinput = $inputs.eq(0);
const $endinput = $inputs.eq(1);
// For many of the properties, assume start and end have the same values
const min = $startinput.bsDatepicker("getStartDate");
const max = $startinput.bsDatepicker("getEndDate");
// Stringify min and max. If min and max aren't set, they will be
// -Infinity and Infinity; replace these with null.
const minStr = min === -Infinity ? null : formatDateUTC(min as Date);
const maxStr = max === Infinity ? null : formatDateUTC(max as Date);
// startViewMode is stored as a number; convert to string
let startview = $startinput.data("datepicker").startView;
if (startview === 2) startview = "decade";
else if (startview === 1) startview = "year";
else if (startview === 0) startview = "month";
return {
label: this._getLabelNode(el).text(),
value: this.getValue(el),
valueString: [$startinput.val() as string, $endinput.val() as string],
min: minStr,
max: maxStr,
weekstart: $startinput.data("datepicker").weekStart,
format: this._formatToString($startinput.data("datepicker").format),
language: $startinput.data("datepicker").language,
startview: startview,
};
}
receiveMessage(el: HTMLElement, data: DateRangeReceiveMessageData): void {
const $el = $(el);
const $inputs = $el.find("input");
const $startinput = $inputs.eq(0);
const $endinput = $inputs.eq(1);
updateLabel(data.label, this._getLabelNode(el));
if (hasOwnProperty(data, "min")) {
this._setMin($startinput[0], data.min);
this._setMin($endinput[0], data.min);
}
if (hasOwnProperty(data, "max")) {
this._setMax($startinput[0], data.max);
this._setMax($endinput[0], data.max);
}
// Must set value only after min and max have been set. If new value is
// outside the bounds of the previous min/max, then the result will be a
// blank input.
if (hasOwnProperty(data, "value")) this.setValue(el, data.value);
$el.trigger("change");
}
initialize(el: HTMLElement): void {
const $el = $(el);
const $inputs = $el.find("input");
const $startinput = $inputs.eq(0);
const $endinput = $inputs.eq(1);
let start = $startinput.data("initial-date");
let end = $endinput.data("initial-date");
// If empty/null, use local date, but as UTC
if (start === undefined || start === null)
start = this._dateAsUTC(new Date());
if (end === undefined || end === null) end = this._dateAsUTC(new Date());
this.setValue(el, { start: start, end: end });
// // Set the start and end dates, from min-date and max-date. These always
// // use yyyy-mm-dd format, instead of bootstrap-datepicker's built-in
// // support for date-startdate and data-enddate, which use the current
// // date format.
this._setMin($startinput[0], $startinput.data("min-date"));
this._setMin($endinput[0], $startinput.data("min-date"));
this._setMax($startinput[0], $endinput.data("max-date"));
this._setMax($endinput[0], $endinput.data("max-date"));
}
subscribe(el: HTMLElement, callback: (x: boolean) => void): void {
$(el).on(
"keyup.dateRangeInputBinding input.dateRangeInputBinding",
// event: Event
function () {
// Use normal debouncing policy when typing
callback(true);
}
);
$(el).on(
"changeDate.dateRangeInputBinding change.dateRangeInputBinding",
// event: Event
function () {
// Send immediately when clicked
callback(false);
}
);
}
unsubscribe(el: HTMLElement): void {
$(el).off(".dateRangeInputBinding");
}
_getLabelNode(el: HTMLElement): JQuery<HTMLElement> {
return $(el).find('label[for="' + $escape(el.id) + '"]');
}
}
export { DateRangeInputBinding };
export type { DateRangeReceiveMessageData };

View File

@@ -0,0 +1,291 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import { FileUploader } from "../../file/FileProcessor";
import { shinyShinyApp } from "../../shiny/initedMethods";
const _ZoneClass = {
ACTIVE: "shiny-file-input-active",
OVER: "shiny-file-input-over",
};
// NOTE On Safari, at least version 10.1.2, *if the developer console is open*,
// setting the input's value will behave strangely because of a Safari bug. The
// uploaded file's name will appear over the placeholder value, instead of
// replacing it. The workaround is to restart Safari. When I (Alan Dipert) ran
// into this bug Winston Chang helped me diagnose the exact problem, and Winston
// then submitted a bug report to Apple.
function setFileText($el: JQuery<EventTarget>, files: FileList) {
const $fileText = $el.closest("div.input-group").find("input[type=text]");
if (files.length === 1) {
$fileText.val(files[0].name);
} else {
$fileText.val(files.length + " files");
}
}
// If previously selected files are uploading, abort that.
function abortCurrentUpload($el: JQuery<EventTarget>) {
const uploader = $el.data("currentUploader");
if (uploader) uploader.abort();
// Clear data-restore attribute if present.
$el.removeAttr("data-restore");
}
function uploadDroppedFilesIE10Plus(
el: HTMLInputElement,
files: FileList
): void {
const $el = $(el);
abortCurrentUpload($el);
// Set the label in the text box
setFileText($el, files);
// Start the new upload and put the uploader in 'currentUploader'.
$el.data(
"currentUploader",
new FileUploader(shinyShinyApp(), fileInputBindingGetId(el), files, el)
);
}
function uploadFiles(evt: JQuery.DragEvent): void {
const $el = $(evt.target);
abortCurrentUpload($el);
const files = evt.target.files;
const id = fileInputBindingGetId(evt.target);
if (files.length === 0) return;
// Set the label in the text box
setFileText($el, files);
// Start the new upload and put the uploader in 'currentUploader'.
$el.data(
"currentUploader",
new FileUploader(shinyShinyApp(), id, files, evt.target)
);
}
// Here we maintain a list of all the current file inputs. This is necessary
// because we need to trigger events on them in order to respond to file drag
// events. For example, they should all light up when a file is dragged on to
// the page.
// TODO-barret ; Should this be an internal class property?
let $fileInputs = $();
function fileInputBindingGetId(el: HTMLInputElement): string {
return InputBinding.prototype.getId.call(this, el) || el.name;
}
class FileInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find('input[type="file"]');
}
getId(el: HTMLInputElement): string {
return fileInputBindingGetId(el);
}
getValue(el: HTMLElement): { name?: string } | null {
// This returns a non-undefined value only when there's a 'data-restore'
// attribute, which is set only when restoring Shiny state. If a file is
// uploaded through the browser, 'data-restore' gets cleared.
const data = $(el).attr("data-restore");
if (data) {
const dataParsed = JSON.parse(data);
// Set the label in the text box
const $fileText = $(el)
.closest("div.input-group")
.find("input[type=text]");
if (dataParsed.name.length === 1) {
$fileText.val(dataParsed.name[0]);
} else {
$fileText.val(dataParsed.name.length + " files");
}
// Manually set up progress bar. A bit inelegant because it duplicates
// code from FileUploader, but duplication is less bad than alternatives.
const $progress = $(el).closest("div.form-group").find(".progress");
const $bar = $progress.find(".progress-bar");
$progress.removeClass("active");
$bar.width("100%");
$bar.css("visibility", "visible");
return dataParsed;
} else {
return null;
}
}
setValue(el: HTMLElement, value: void): void {
// Not implemented
el;
value;
}
getType(el: HTMLElement): string {
// This will be used only when restoring a file from a saved state.
return "shiny.file";
el;
}
_zoneOf(el: HTMLElement | JQuery<HTMLElement>): JQuery<HTMLElement> {
return $(el).closest("div.input-group");
}
// This function makes it possible to attach listeners to the dragenter,
// dragleave, and drop events of a single element with children. It's not
// intuitive to do directly because outer elements fire "dragleave" events
// both when the drag leaves the element and when the drag enters a child. To
// make it easier, we maintain a count of the elements being dragged across
// and trigger 3 new types of event:
//
// 1. draghover:enter - When a drag enters el and any of its children.
// 2. draghover:leave - When the drag leaves el and all of its children.
// 3. draghover:drop - When an item is dropped on el or any of its children.
_enableDraghover(el: JQuery<HTMLElement>): JQuery<HTMLElement> {
const $el = $(el);
let childCounter = 0;
$el.on({
"dragenter.draghover": (e) => {
if (childCounter++ === 0) {
$el.trigger("draghover:enter", e);
}
},
"dragleave.draghover": (e) => {
if (--childCounter === 0) {
$el.trigger("draghover:leave", e);
}
if (childCounter < 0) {
console.error("draghover childCounter is negative somehow");
}
},
"dragover.draghover": (e) => {
e.preventDefault();
},
"drop.draghover": (e) => {
childCounter = 0;
$el.trigger("draghover:drop", e);
e.preventDefault();
},
});
return $el;
}
_disableDraghover(el: JQuery<HTMLElement>): JQuery<HTMLElement> {
return $(el).off(".draghover");
}
_enableDocumentEvents(): void {
const $doc = $("html"),
{ ACTIVE, OVER } = _ZoneClass;
this._enableDraghover($doc).on({
"draghover:enter.draghover":
// e: Event
() => {
this._zoneOf($fileInputs).addClass(ACTIVE);
},
"draghover:leave.draghover":
// e: Event
() => {
this._zoneOf($fileInputs).removeClass(ACTIVE);
},
"draghover:drop.draghover":
// e: Event
() => {
this._zoneOf($fileInputs).removeClass(OVER).removeClass(ACTIVE);
},
});
}
_disableDocumentEvents(): void {
const $doc = $("html");
$doc.off(".draghover");
this._disableDraghover($doc);
}
_canSetFiles(fileList: FileList): boolean {
const testEl = document.createElement("input");
testEl.type = "file";
try {
testEl.files = fileList;
} catch (e) {
return false;
}
return true;
}
_handleDrop(e: JQuery.DragEventBase, el: HTMLInputElement): void {
const files = e.originalEvent.dataTransfer.files,
$el = $(el);
if (files === undefined || files === null) {
// 1. The FileList object isn't supported by this browser, and
// there's nothing else we can try. (< IE 10)
console.log(
"Dropping files is not supported on this browser. (no FileList)"
);
} else if (!this._canSetFiles(files)) {
// 2. The browser doesn't support assigning a type=file input's .files
// property, but we do have a FileList to work with. (IE10+/Edge)
$el.val("");
uploadDroppedFilesIE10Plus(el, files);
} else {
// 3. The browser supports FileList and input.files assignment.
// (Chrome, Safari)
$el.val("");
el.files = e.originalEvent.dataTransfer.files;
// Recent versions of Firefox (57+, or "Quantum" and beyond) don't seem to
// automatically trigger a change event, so we trigger one manually here.
// On browsers that do trigger change, this operation appears to be
// idempotent, as el.files doesn't change between events.
$el.trigger("change");
}
}
subscribe(el: HTMLInputElement, callback: (x: boolean) => void): void {
callback;
$(el).on("change.fileInputBinding", uploadFiles);
// Here we try to set up the necessary events for Drag and Drop ("DnD").
if ($fileInputs.length === 0) this._enableDocumentEvents();
$fileInputs = $fileInputs.add(el);
const $zone = this._zoneOf(el),
{ OVER } = _ZoneClass;
this._enableDraghover($zone).on({
"draghover:enter.draghover": (e) => {
e;
$zone.addClass(OVER);
},
"draghover:leave.draghover": (e) => {
$zone.removeClass(OVER);
// Prevent this event from bubbling to the document handler,
// which would deactivate all zones.
e.stopPropagation();
},
"draghover:drop.draghover": (e, dropEvent) => {
e;
this._handleDrop(dropEvent, el);
},
});
}
unsubscribe(el: HTMLElement): void {
const $el = $(el),
$zone = this._zoneOf(el);
$zone.removeClass(_ZoneClass.OVER).removeClass(_ZoneClass.ACTIVE);
this._disableDraghover($zone);
$el.off(".fileInputBinding");
$zone.off(".draghover");
// Remove el from list of inputs and (maybe) clean up global event handlers.
$fileInputs = $fileInputs.not(el);
if ($fileInputs.length === 0) this._disableDocumentEvents();
}
}
export { FileInputBinding };

View File

@@ -0,0 +1,57 @@
import { BindingRegistry } from "../registry";
import { InputBinding } from "./InputBinding";
import { CheckboxInputBinding } from "./checkbox";
import { CheckboxGroupInputBinding } from "./checkboxgroup";
import { NumberInputBinding } from "./number";
import { PasswordInputBinding } from "./password";
import { TextInputBinding } from "./text";
import { TextareaInputBinding } from "./textarea";
import { RadioInputBinding } from "./radio";
import { DateInputBinding } from "./date";
import { SliderInputBinding } from "./slider";
import { DateRangeInputBinding } from "./daterange";
import { SelectInputBinding } from "./selectInput";
import { ActionButtonInputBinding } from "./actionbutton";
import { BootstrapTabInputBinding } from "./tabinput";
import { FileInputBinding } from "./fileinput";
// TODO-barret make this an init method
type InitInputBindings = {
inputBindings: BindingRegistry<InputBinding>;
fileInputBinding: FileInputBinding;
};
function initInputBindings(): InitInputBindings {
const inputBindings = new BindingRegistry<InputBinding>();
inputBindings.register(new TextInputBinding(), "shiny.textInput");
inputBindings.register(new TextareaInputBinding(), "shiny.textareaInput");
inputBindings.register(new PasswordInputBinding(), "shiny.passwordInput");
inputBindings.register(new NumberInputBinding(), "shiny.numberInput");
inputBindings.register(new CheckboxInputBinding(), "shiny.checkboxInput");
inputBindings.register(
new CheckboxGroupInputBinding(),
"shiny.checkboxGroupInput"
);
inputBindings.register(new RadioInputBinding(), "shiny.radioInput");
inputBindings.register(new SliderInputBinding(), "shiny.sliderInput");
inputBindings.register(new DateInputBinding(), "shiny.dateInput");
inputBindings.register(new DateRangeInputBinding(), "shiny.dateRangeInput");
inputBindings.register(new SelectInputBinding(), "shiny.selectInput");
inputBindings.register(
new ActionButtonInputBinding(),
"shiny.actionButtonInput"
);
inputBindings.register(
new BootstrapTabInputBinding(),
"shiny.bootstrapTabInput"
);
const fileInputBinding = new FileInputBinding();
inputBindings.register(fileInputBinding, "shiny.fileInputBinding");
return { inputBindings, fileInputBinding };
}
export { initInputBindings, InputBinding };

View File

@@ -0,0 +1,80 @@
import $ from "jquery";
import { $escape, hasOwnProperty, updateLabel } from "../../utils";
import { TextInputBindingBase } from "./text";
type NumberHTMLElement = HTMLInputElement;
type NumberReceiveMessageData = {
label: string;
value?: string | null;
min?: string | null;
max?: string | null;
step?: string | null;
};
class NumberInputBinding extends TextInputBindingBase {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find('input[type="number"]');
}
getValue(el: NumberHTMLElement): string | number | string[] {
const numberVal = $(el).val();
if (typeof numberVal == "string") {
if (/^\s*$/.test(numberVal))
// Return null if all whitespace
return null;
}
// If valid Javascript number string, coerce to number
const numberValue = Number(numberVal);
if (!isNaN(numberValue)) {
return numberValue;
}
return numberVal; // If other string like "1e6", send it unchanged
}
setValue(el: NumberHTMLElement, value: number): void {
el.value = "" + value;
}
getType(el: NumberHTMLElement): string {
return "shiny.number";
el;
}
receiveMessage(el: NumberHTMLElement, data: NumberReceiveMessageData): void {
if (hasOwnProperty(data, "value")) el.value = data.value;
if (hasOwnProperty(data, "min")) el.min = data.min;
if (hasOwnProperty(data, "max")) el.max = data.max;
if (hasOwnProperty(data, "step")) el.step = data.step;
updateLabel(data.label, this._getLabelNode(el));
$(el).trigger("change");
}
getState(el: NumberHTMLElement): {
label: string;
value: ReturnType<NumberInputBinding["getValue"]>;
min: number;
max: number;
step: number;
} {
return {
label: this._getLabelNode(el).text(),
value: this.getValue(el),
min: Number(el.min),
max: Number(el.max),
step: Number(el.step),
};
}
_getLabelNode(el: NumberHTMLElement): JQuery<HTMLElement> {
return $(el)
.parent()
.find('label[for="' + $escape(el.id) + '"]');
}
}
export { NumberInputBinding };
export type { NumberReceiveMessageData };

View File

@@ -0,0 +1,16 @@
import $ from "jquery";
import { TextInputBinding } from "./text";
class PasswordInputBinding extends TextInputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find('input[type="password"]');
}
getType(el: HTMLElement): string {
return "shiny.password";
el;
}
}
export { PasswordInputBinding };

View File

@@ -0,0 +1,128 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import { $escape, hasOwnProperty, updateLabel } from "../../utils";
type RadioHTMLElement = HTMLInputElement;
type ValueLabelObject = {
value: HTMLInputElement["value"];
label: string;
};
type RadioReceiveMessageData = {
value?: string;
options?: Array<ValueLabelObject>;
label: string;
};
class RadioInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-input-radiogroup");
}
getValue(el: RadioHTMLElement): string | number | string[] | null {
// Select the radio objects that have name equal to the grouping div's id
const checkedItems = $(
'input:radio[name="' + $escape(el.id) + '"]:checked'
);
if (checkedItems.length === 0) {
// If none are checked, the input will return null (it's the default on load,
// but it wasn't emptied when calling updateRadioButtons with character(0)
return null;
}
return checkedItems.val();
}
setValue(el: RadioHTMLElement, value: string): void {
if ($.isArray(value) && value.length === 0) {
// Removing all checked item if the sent data is empty
$('input:radio[name="' + $escape(el.id) + '"]').prop("checked", false);
} else {
$(
'input:radio[name="' +
$escape(el.id) +
'"][value="' +
$escape(value) +
'"]'
).prop("checked", true);
}
}
getState(el: RadioHTMLElement): {
label: string;
value: string | number | string[];
options: Array<ValueLabelObject>;
} {
const $objs = $(
'input:radio[name="' + $escape(el.id) + '"]'
) as JQuery<RadioHTMLElement>;
// Store options in an array of objects, each with with value and label
const options = new Array($objs.length);
for (let i = 0; i < options.length; i++) {
options[i] = { value: $objs[i].value, label: this._getLabel($objs[i]) };
}
return {
label: this._getLabelNode(el).text(),
value: this.getValue(el),
options: options,
};
}
receiveMessage(el: RadioHTMLElement, data: RadioReceiveMessageData): void {
const $el = $(el);
// This will replace all the options
if (hasOwnProperty(data, "options")) {
// Clear existing options and add each new one
$el.find("div.shiny-options-group").remove();
// Backward compatibility: for HTML generated by shinybootstrap2 package
$el.find("label.radio").remove();
// @ts-expect-error; TODO-barret; IDK what this line is doing
$el.append(data.options);
}
if (hasOwnProperty(data, "value")) this.setValue(el, data.value);
updateLabel(data.label, this._getLabelNode(el));
$(el).trigger("change");
}
subscribe(el: RadioHTMLElement, callback: (x: boolean) => void): void {
$(el).on("change.radioInputBinding", function () {
callback(false);
});
}
unsubscribe(el: RadioHTMLElement): void {
$(el).off(".radioInputBinding");
}
// Get the DOM element that contains the top-level label
_getLabelNode(el: RadioHTMLElement): JQuery<HTMLElement> {
return $(el)
.parent()
.find('label[for="' + $escape(el.id) + '"]');
}
// Given an input DOM object, get the associated label. Handles labels
// that wrap the input as well as labels associated with 'for' attribute.
_getLabel(obj: HTMLElement): string | null {
// If <label><input /><span>label text</span></label>
if ((obj.parentNode as HTMLElement).tagName === "LABEL") {
return $(obj.parentNode).find("span").text().trim();
}
return null;
}
// Given an input DOM object, set the associated label. Handles labels
// that wrap the input as well as labels associated with 'for' attribute.
_setLabel(obj: HTMLElement, value: string): null {
// If <label><input /><span>label text</span></label>
if ((obj.parentNode as HTMLElement).tagName === "LABEL") {
$(obj.parentNode).find("span").text(value);
}
return null;
}
}
export { RadioInputBinding };
export type { RadioReceiveMessageData };

View File

@@ -0,0 +1,279 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import { $escape, hasOwnProperty, updateLabel } from "../../utils";
import { indirectEval } from "../../utils/eval";
type SelectHTMLElement = HTMLSelectElement & { nonempty: boolean };
type SelectInputReceiveMessageData = {
label: string;
options?: string;
config?: string;
url?: string;
value?: string;
};
type SelectizeInfo = Selectize.IApi<string, unknown> & {
settings: Selectize.IOptions<string, unknown>;
};
class SelectInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find("select");
}
getType(el: HTMLElement): string {
const $el = $(el);
if (!$el.hasClass("symbol")) {
// default character type
return null;
}
if ($el.attr("multiple") === "multiple") {
return "shiny.symbolList";
} else {
return "shiny.symbol";
}
}
getId(el: SelectHTMLElement): string {
return InputBinding.prototype.getId.call(this, el) || el.name;
}
getValue(el: HTMLElement): string | number | string[] {
return $(el).val();
}
setValue(el: SelectHTMLElement, value: string): void {
if (!this._is_selectize(el)) {
$(el).val(value);
} else {
const selectize = this._selectize(el);
if (selectize) {
selectize.setValue(value);
}
}
}
getState(el: SelectHTMLElement): {
label: JQuery<HTMLElement>;
value: string | number | string[];
options: Array<{ value: string; label: string }>;
} {
// Store options in an array of objects, each with with value and label
const options: Array<{ value: string; label: string }> = new Array(
el.length
);
for (let i = 0; i < el.length; i++) {
options[i] = {
// TODO-barret; Is this a safe assumption?; Are there no Option Groups?
value: (el[i] as HTMLOptionElement).value,
label: el[i].label,
};
}
return {
label: this._getLabelNode(el),
value: this.getValue(el),
options: options,
};
}
receiveMessage(
el: SelectHTMLElement,
data: SelectInputReceiveMessageData
): void {
const $el = $(el);
let selectize;
// This will replace all the options
if (hasOwnProperty(data, "options")) {
selectize = this._selectize(el);
// Must destroy selectize before appending new options, otherwise
// selectize will restore the original select
if (selectize) selectize.destroy();
// Clear existing options and add each new one
$el.empty().append(data.options);
this._selectize(el);
}
// re-initialize selectize
if (hasOwnProperty(data, "config")) {
$el
.parent()
.find('script[data-for="' + $escape(el.id) + '"]')
.replaceWith(data.config);
this._selectize(el, true);
}
// use server-side processing for selectize
if (hasOwnProperty(data, "url")) {
selectize = this._selectize(el);
selectize.clearOptions();
let loaded = false;
selectize.settings.load = function (query, callback) {
const settings = selectize.settings;
$.ajax({
url: data.url,
data: {
query: query,
field: JSON.stringify([settings.searchField]),
value: settings.valueField,
conju: settings.searchConjunction,
maxop: settings.maxOptions,
},
type: "GET",
error: function () {
callback();
},
success: function (res) {
// res = [{label: '1', value: '1', group: '1'}, ...]
// success is called after options are added, but
// groups need to be added manually below
$.each(res, function (index, elem) {
// Call selectize.addOptionGroup once for each optgroup; the
// first argument is the group ID, the second is an object with
// the group's label and value. We use the current settings of
// the selectize object to decide the fieldnames of that obj.
const optgroupId = elem[settings.optgroupField || "optgroup"];
const optgroup = {};
optgroup[settings.optgroupLabelField || "label"] = optgroupId;
optgroup[settings.optgroupValueField || "value"] = optgroupId;
selectize.addOptionGroup(optgroupId, optgroup);
});
callback(res);
if (!loaded) {
if (hasOwnProperty(data, "value")) {
selectize.setValue(data.value);
} else if (settings.maxItems === 1) {
// first item selected by default only for single-select
selectize.setValue(res[0].value);
}
}
loaded = true;
},
});
};
// perform an empty search after changing the `load` function
selectize.load(function (callback) {
selectize.settings.load.apply(selectize, ["", callback]);
});
} else if (hasOwnProperty(data, "value")) {
this.setValue(el, data.value);
}
updateLabel(data.label, this._getLabelNode(el));
$(el).trigger("change");
}
subscribe(el: SelectHTMLElement, callback: (x: boolean) => void): void {
$(el).on(
"change.selectInputBinding",
// event: Event
() => {
// https://github.com/rstudio/shiny/issues/2162
// Prevent spurious events that are gonna be squelched in
// a second anyway by the onItemRemove down below
if (el.nonempty && this.getValue(el) === "") {
return;
}
callback(false);
}
);
}
unsubscribe(el: HTMLElement): void {
$(el).off(".selectInputBinding");
}
initialize(el: SelectHTMLElement): void {
this._selectize(el);
}
_getLabelNode(el: SelectHTMLElement): JQuery<HTMLElement> {
let escapedId = $escape(el.id);
if (this._is_selectize(el)) {
escapedId += "-selectized";
}
return $(el)
.parent()
.parent()
.find('label[for="' + escapedId + '"]');
}
// Return true if it's a selectize input, false if it's a regular select input.
// eslint-disable-next-line camelcase
_is_selectize(el: HTMLElement): boolean {
const config = $(el)
.parent()
.find('script[data-for="' + $escape(el.id) + '"]');
return config.length > 0;
}
_selectize(el: SelectHTMLElement, update = false): SelectizeInfo {
if (!$.fn.selectize) return undefined;
const $el = $(el);
const config = $el
.parent()
.find('script[data-for="' + $escape(el.id) + '"]');
if (config.length === 0) return undefined;
let options: {
labelField: "label";
valueField: "value";
searchField: ["label"];
onItemRemove?: (value: string) => void;
onDropdownClose?: () => void;
} & Record<string, unknown> = $.extend(
{
labelField: "label",
valueField: "value",
searchField: ["label"],
},
JSON.parse(config.html())
);
// selectize created from selectInput()
if (typeof config.data("nonempty") !== "undefined") {
el.nonempty = true;
options = $.extend(options, {
onItemRemove: function (value) {
if (this.getValue() === "")
$("select#" + $escape(el.id))
.empty()
.append(
$("<option/>", {
value: value,
selected: true,
})
)
.trigger("change");
},
onDropdownClose:
// $dropdown: any
function () {
if (this.getValue() === "")
this.setValue($("select#" + $escape(el.id)).val());
},
});
} else {
el.nonempty = false;
}
// options that should be eval()ed
if (config.data("eval") instanceof Array)
$.each(config.data("eval"), function (i, x) {
/*jshint evil: true*/
options[x] = indirectEval("(" + options[x] + ")");
});
let control = $el.selectize(options)[0].selectize as SelectizeInfo;
// .selectize() does not really update settings; must destroy and rebuild
if (update) {
const settings = $.extend(control.settings, options);
control.destroy();
control = $el.selectize(settings)[0].selectize as SelectizeInfo;
}
return control;
}
}
export { SelectInputBinding };
export type { SelectInputReceiveMessageData };

View File

@@ -0,0 +1,390 @@
import $ from "jquery";
// import { NameValueHTMLElement } from ".";
import {
formatDateUTC,
updateLabel,
$escape,
hasOwnProperty,
} from "../../utils";
import { TextHTMLElement, TextInputBindingBase } from "./text";
// interface SliderHTMLElement extends NameValueHTMLElement {
// checked?: any;
// }
type TimeFormatter = (fmt: string, dt: Date) => string;
type legacySliderType = {
canStepNext: () => boolean;
stepNext: () => void;
resetToStart: () => void;
};
type SliderReceiveMessageData = {
label: string;
value?: Array<string | number> | string | number;
min?: number;
max?: number;
step?: number;
};
// MUST use window.strftime as the javascript dependency is dynamic
// and could be needed after shiny has initialized.
declare global {
interface Window {
strftime: {
utc: () => TimeFormatter;
timezone: (timezone: string) => TimeFormatter;
} & TimeFormatter;
}
interface JQuery {
// Backward compatible code for old-style jsliders (Shiny <= 0.10.2.2),
slider: () => legacySliderType;
}
}
// Necessary to get hidden sliders to send their updated values
function forceIonSliderUpdate(slider) {
if (slider.$cache && slider.$cache.input)
slider.$cache.input.trigger("change");
else console.log("Couldn't force ion slider to update");
}
type prettifyType = (num: number) => string;
function getTypePrettifyer(
dataType: string,
timeFormat: string,
timezone: string
) {
let timeFormatter: TimeFormatter;
let prettify: prettifyType;
if (dataType === "date") {
timeFormatter = window.strftime.utc();
prettify = function (num) {
return timeFormatter(timeFormat, new Date(num));
};
} else if (dataType === "datetime") {
if (timezone) timeFormatter = window.strftime.timezone(timezone);
else timeFormatter = window.strftime;
prettify = function (num) {
return timeFormatter(timeFormat, new Date(num));
};
} else {
// The default prettify function for ion.rangeSlider adds thousands
// separators after the decimal mark, so we have our own version here.
// (#1958)
prettify = function (num) {
// When executed, `this` will refer to the `IonRangeSlider.options`
// object.
return formatNumber(num, this.prettify_separator);
};
}
return prettify;
}
class SliderInputBinding extends TextInputBindingBase {
find(scope: HTMLElement): JQuery<HTMLElement> {
// Check if ionRangeSlider plugin is loaded
if (!$.fn.ionRangeSlider) {
// Return empty set of _found_ items
return $();
}
return $(scope).find("input.js-range-slider");
}
getType(el: HTMLElement): string | false {
const dataType = $(el).data("data-type");
if (dataType === "date") return "shiny.date";
else if (dataType === "datetime") return "shiny.datetime";
else return false;
}
getValue(
el: TextHTMLElement
): number | string | [number | string, number | string] {
const $el = $(el);
const result = $(el).data("ionRangeSlider").result;
// Function for converting numeric value from slider to appropriate type.
let convert: (val: unknown) => number | string;
const dataType = $el.data("data-type");
if (dataType === "date") {
convert = function (val: unknown) {
return formatDateUTC(new Date(Number(val)));
};
} else if (dataType === "datetime") {
convert = function (val: unknown) {
// Convert ms to s
return Number(val) / 1000;
};
} else {
convert = function (val: unknown) {
return Number(val);
};
}
if (this._numValues(el) === 2) {
return [convert(result.from), convert(result.to)];
} else {
return convert(result.from);
}
}
setValue(
el: HTMLElement,
value: number | string | [number | string, number | string]
): void {
const $el = $(el);
const slider = $el.data("ionRangeSlider");
$el.data("immediate", true);
try {
if (this._numValues(el) === 2 && value instanceof Array) {
slider.update({ from: value[0], to: value[1] });
} else {
slider.update({ from: value });
}
forceIonSliderUpdate(slider);
} finally {
$el.data("immediate", false);
}
}
subscribe(el: HTMLElement, callback: (x: boolean) => void): void {
$(el).on("change.sliderInputBinding", function () {
callback(!$(el).data("immediate") && !$(el).data("animating"));
});
}
unsubscribe(el: HTMLElement): void {
$(el).off(".sliderInputBinding");
}
receiveMessage(el: HTMLElement, data: SliderReceiveMessageData): void {
const $el = $(el);
const slider = $el.data("ionRangeSlider");
const msg: {
from?: string | number;
to?: string | number;
min?: number;
max?: number;
step?: number;
prettify?: prettifyType;
} = {};
if (hasOwnProperty(data, "value")) {
if (this._numValues(el) === 2 && data.value instanceof Array) {
msg.from = data.value[0];
msg.to = data.value[1];
} else {
msg.from = data.value as string | number;
}
}
const sliderFeatures = ["min", "max", "step"];
for (let i = 0; i < sliderFeatures.length; i++) {
const feats = sliderFeatures[i];
if (hasOwnProperty(data, feats)) {
msg[feats] = data[feats];
}
}
updateLabel(data.label, this._getLabelNode(el));
// (maybe) update data elements
const domElements = ["data-type", "time-format", "timezone"];
for (let i = 0; i < domElements.length; i++) {
const elem = domElements[i];
if (hasOwnProperty(data, elem)) {
$el.data(elem, data[elem]);
}
}
// retrieve latest data values
const dataType = $el.data("data-type");
const timeFormat = $el.data("time-format");
const timezone = $el.data("timezone");
msg.prettify = getTypePrettifyer(dataType, timeFormat, timezone);
$el.data("immediate", true);
try {
slider.update(msg);
forceIonSliderUpdate(slider);
} finally {
$el.data("immediate", false);
}
}
getRatePolicy(el: HTMLElement): { policy: "debounce"; delay: 250 } {
return {
policy: "debounce",
delay: 250,
};
el;
}
// TODO-barret Why not implemented?
getState(el: HTMLInputElement): void {
// empty
el;
}
initialize(el: HTMLElement): void {
const $el = $(el);
const dataType = $el.data("data-type");
const timeFormat = $el.data("time-format");
const timezone = $el.data("timezone");
const opts = {
prettify: getTypePrettifyer(dataType, timeFormat, timezone),
};
$el.ionRangeSlider(opts);
}
_getLabelNode(el: HTMLElement): JQuery<HTMLElement> {
return $(el)
.parent()
.find('label[for="' + $escape(el.id) + '"]');
}
// Number of values; 1 for single slider, 2 for range slider
_numValues(el: HTMLElement): 1 | 2 {
if ($(el).data("ionRangeSlider").options.type === "double") return 2;
else return 1;
}
}
// Format numbers for nicer output.
// formatNumber(1234567.12345) === "1,234,567.12345"
// formatNumber(1234567.12345, ".", ",") === "1.234.567,12345"
// formatNumber(1000, " ") === "1 000"
// formatNumber(20) === "20"
// formatNumber(1.2345e24) === "1.2345e+24"
function formatNumber(
num: number,
thousandSep = ",",
decimalSep = "."
): string {
const parts = num.toString().split(".");
// Add separators to portion before decimal mark.
parts[0] = parts[0].replace(
/(\d{1,3}(?=(?:\d\d\d)+(?!\d)))/g,
"$1" + thousandSep
);
if (parts.length === 1) return parts[0];
else if (parts.length === 2) return parts[0] + decimalSep + parts[1];
else return "";
}
// TODO-barret ; this should be put in the "init" areas, correct?
$(document).on("click", ".slider-animate-button", function (evt: Event) {
evt.preventDefault();
const self = $(this);
const target = $("#" + $escape(self.attr("data-target-id")));
const startLabel = "Play";
const stopLabel = "Pause";
const loop =
self.attr("data-loop") !== undefined &&
!/^\s*false\s*$/i.test(self.attr("data-loop"));
let animInterval = self.attr("data-interval") as number | string;
if (isNaN(animInterval as number)) animInterval = 1500;
else animInterval = Number(animInterval);
if (!target.data("animTimer")) {
let timer;
// Separate code paths:
// Backward compatible code for old-style jsliders (Shiny <= 0.10.2.2),
// and new-style ionsliders.
if (target.hasClass("jslider")) {
const slider = target.slider();
// If we're currently at the end, restart
if (!slider.canStepNext()) slider.resetToStart();
timer = setInterval(function () {
if (loop && !slider.canStepNext()) {
slider.resetToStart();
} else {
slider.stepNext();
if (!loop && !slider.canStepNext()) {
// TODO-barret replace with self.trigger("click")
self.click(); // stop the animation
}
}
}, animInterval);
} else {
const slider = target.data("ionRangeSlider");
// Single sliders have slider.options.type == "single", and only the
// `from` value is used. Double sliders have type == "double", and also
// use the `to` value for the right handle.
const sliderCanStep = function () {
if (slider.options.type === "double")
return slider.result.to < slider.result.max;
else return slider.result.from < slider.result.max;
};
const sliderReset = function () {
const val: { from: number; to?: number } = { from: slider.result.min };
// Preserve the current spacing for double sliders
if (slider.options.type === "double")
val.to = val.from + (slider.result.to - slider.result.from);
slider.update(val);
forceIonSliderUpdate(slider);
};
const sliderStep = function () {
// Don't overshoot the end
const val: { from: number; to?: number } = {
from: Math.min(
slider.result.max,
slider.result.from + slider.options.step
),
};
if (slider.options.type === "double")
val.to = Math.min(
slider.result.max,
slider.result.to + slider.options.step
);
slider.update(val);
forceIonSliderUpdate(slider);
};
// If we're currently at the end, restart
if (!sliderCanStep()) sliderReset();
timer = setInterval(function () {
if (loop && !sliderCanStep()) {
sliderReset();
} else {
sliderStep();
if (!loop && !sliderCanStep()) {
self.click(); // stop the animation
}
}
}, animInterval);
}
target.data("animTimer", timer);
self.attr("title", stopLabel);
self.addClass("playing");
target.data("animating", true);
} else {
clearTimeout(target.data("animTimer"));
target.removeData("animTimer");
self.attr("title", startLabel);
self.removeClass("playing");
target.removeData("animating");
}
});
export { SliderInputBinding };
export type { SliderReceiveMessageData };

View File

@@ -0,0 +1,78 @@
import $ from "jquery";
import { InputBinding } from "./InputBinding";
import { hasOwnProperty, isBS3 } from "../../utils";
type TabInputReceiveMessageData = { value?: string };
class BootstrapTabInputBinding extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find("ul.nav.shiny-tab-input");
}
getValue(el: HTMLElement): string | null {
// prettier-ignore
// The BS4+ selectors may not work as is for dropdowns within dropdowns, but BS3+ dropped support for those anyway
const anchor = isBS3()
? $(el).find("li:not(.dropdown).active > a")
: $(el).find(
".nav-link:not(.dropdown-toggle).active, .dropdown-menu .dropdown-item.active"
);
if (anchor.length === 1) return this._getTabName(anchor);
return null;
}
setValue(el: HTMLElement, value: string): void {
// this is required as an arrow function will not fix the usage
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
let success = false;
if (value) {
// prettier-ignore
// The BS4+ selectors may not work as is for dropdowns within dropdowns, but BS3+ dropped support for those anyway
const anchors = isBS3()
? $(el).find("li:not(.dropdown) > a")
: $(el).find(
".nav-link:not(.dropdown-toggle), .dropdown-menu .dropdown-item"
);
anchors.each(function () {
if (self._getTabName($(this)) === value) {
$(this).tab("show");
success = true;
return false; // Break out of each()
}
return;
});
}
if (!success) {
// This is to handle the case where nothing is selected, e.g. the last tab
// was removed using removeTab.
$(el).trigger("change");
}
}
getState(el: HTMLElement): { value: string | null } {
return { value: this.getValue(el) };
}
receiveMessage(el: HTMLElement, data: TabInputReceiveMessageData): void {
if (hasOwnProperty(data, "value")) this.setValue(el, data.value);
$(el).trigger("change");
}
subscribe(el: HTMLElement, callback: (x: boolean) => void): void {
$(el).on(
"change shown.bootstrapTabInputBinding shown.bs.tab.bootstrapTabInputBinding",
// event: Event
function () {
callback(false);
}
);
}
unsubscribe(el: HTMLElement): void {
$(el).off(".bootstrapTabInputBinding");
}
_getTabName(anchor: JQuery<HTMLElement>): string {
return anchor.attr("data-value") || anchor.text();
}
}
export { BootstrapTabInputBinding };
export type { TabInputReceiveMessageData };

View File

@@ -0,0 +1,124 @@
import $ from "jquery";
import { $escape, updateLabel, hasOwnProperty } from "../../utils";
import { InputBinding } from "./InputBinding";
// interface TextHTMLElement extends NameValueHTMLElement {
// placeholder: any;
// }
type TextHTMLElement = HTMLInputElement;
type TextReceiveMessageData = {
label: string;
value?: TextHTMLElement["value"];
placeholder?: TextHTMLElement["placeholder"];
};
class TextInputBindingBase extends InputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
const $inputs = $(scope).find(
'input[type="text"], input[type="search"], input[type="url"], input[type="email"]'
);
// selectize.js 0.12.4 inserts a hidden text input with an
// id that ends in '-selectized'. The .not() selector below
// is to prevent textInputBinding from accidentally picking up
// this hidden element as a shiny input (#2396)
return $inputs.not('input[type="text"][id$="-selectized"]');
}
getId(el: TextHTMLElement): string {
return super.getId(el) || el.name;
// return InputBinding.prototype.getId.call(this, el) || el.name;
}
getValue(el: TextHTMLElement): unknown {
throw "not implemented";
el;
}
setValue(el: TextHTMLElement, value: unknown): void {
throw "not implemented";
el;
value;
}
subscribe(el: TextHTMLElement, callback: (x: boolean) => void): void {
$(el).on(
"keyup.textInputBinding input.textInputBinding",
// event: Event
function () {
callback(true);
}
);
$(el).on(
"change.textInputBinding",
// event: Event
function () {
callback(false);
}
);
}
unsubscribe(el: TextHTMLElement): void {
$(el).off(".textInputBinding");
}
receiveMessage(el: TextHTMLElement, data: unknown): void {
throw "not implemented";
el;
data;
}
getState(el: TextHTMLElement): unknown {
throw "not implemented";
el;
}
getRatePolicy(el: HTMLElement): { policy: "debounce"; delay: 250 } {
return {
policy: "debounce",
delay: 250,
};
el;
}
_getLabelNode(el: HTMLElement): JQuery<HTMLElement> {
return $(el)
.parent()
.find('label[for="' + $escape(el.id) + '"]');
}
}
class TextInputBinding extends TextInputBindingBase {
setValue(el: TextHTMLElement, value: string): void {
el.value = value;
}
getValue(el: TextHTMLElement): TextHTMLElement["value"] {
return el.value;
}
getState(el: TextHTMLElement): {
label: string;
value: string;
placeholder: string;
} {
return {
label: this._getLabelNode(el).text(),
value: el.value,
placeholder: el.placeholder,
};
}
receiveMessage(el: TextHTMLElement, data: TextReceiveMessageData): void {
if (hasOwnProperty(data, "value")) this.setValue(el, data.value);
updateLabel(data.label, this._getLabelNode(el));
if (hasOwnProperty(data, "placeholder")) el.placeholder = data.placeholder;
$(el).trigger("change");
}
}
export { TextInputBinding, TextInputBindingBase };
export type { TextHTMLElement, TextReceiveMessageData };

View File

@@ -0,0 +1,11 @@
import $ from "jquery";
import { TextInputBinding } from "./text";
class TextareaInputBinding extends TextInputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find("textarea");
}
}
export { TextareaInputBinding };

View File

@@ -0,0 +1,64 @@
import $ from "jquery";
import { asArray } from "../../utils";
import type { errorsMessageValue } from "../../shiny/shinyapp";
class OutputBinding {
name: string;
// Returns a jQuery object or element array that contains the
// descendants of scope that match this binding
find(scope: JQuery<HTMLElement> | HTMLElement): JQuery<HTMLElement> {
throw "Not implemented";
scope;
}
renderValue(el: HTMLElement, data: unknown): void {
throw "Not implemented";
el;
data;
}
getId(el: HTMLElement): string {
return el["data-input-id"] || el.id;
}
onValueChange(el: HTMLElement, data: unknown): void {
this.clearError(el);
this.renderValue(el, data);
}
onValueError(el: HTMLElement, err: errorsMessageValue): void {
this.renderError(el, err);
}
renderError(el: HTMLElement, err: errorsMessageValue): void {
this.clearError(el);
if (err.message === "") {
// not really error, but we just need to wait (e.g. action buttons)
$(el).empty();
return;
}
let errClass = "shiny-output-error";
if (err.type !== null) {
// use the classes of the error condition as CSS class names
errClass =
errClass +
" " +
$.map(asArray(err.type), function (type) {
return errClass + "-" + type;
}).join(" ");
}
$(el).addClass(errClass).text(err.message);
}
clearError(el: HTMLElement): void {
$(el).attr("class", function (i, c) {
return c.replace(/(^|\s)shiny-output-error\S*/g, "");
});
}
showProgress(el: HTMLElement, show: boolean): void {
const RECALC_CLASS = "recalculating";
if (show) $(el).addClass(RECALC_CLASS);
else $(el).removeClass(RECALC_CLASS);
}
}
export { OutputBinding };

View File

@@ -0,0 +1,137 @@
import $ from "jquery";
import { OutputBinding } from "./OutputBinding";
import { shinyUnbindAll } from "../../shiny/initedMethods";
import { debounce } from "../../time";
import { escapeHTML } from "../../utils";
import { indirectEval } from "../../utils/eval";
import type { errorsMessageValue } from "../../shiny/shinyapp";
class DatatableOutputBinding extends OutputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-datatable-output");
}
onValueError(el: HTMLElement, err: errorsMessageValue): void {
shinyUnbindAll(el);
this.renderError(el, err);
}
renderValue(
el: HTMLElement,
data: null | {
colnames?: Array<string>;
options?: null | {
searching?: boolean;
search?: { caseInsensitive?: boolean };
};
action?: string;
escape?: string;
evalOptions?: Array<string>;
callback?: string;
searchDelay?: number;
}
): void {
const $el = $(el).empty();
if (!data || !data.colnames) return;
const colnames = $.makeArray(data.colnames);
let header = $.map(colnames, function (x) {
return "<th>" + x + "</th>";
}).join("");
header = "<thead><tr>" + header + "</tr></thead>";
let footer = "";
if (data.options === null || data.options.searching !== false) {
footer = $.map(colnames, function (x) {
// placeholder needs to be escaped (and HTML tags are stripped off)
return (
'<th><input type="text" placeholder="' +
escapeHTML(x.replace(/(<([^>]+)>)/gi, "")) +
'" /></th>'
);
}).join("");
footer = "<tfoot>" + footer + "</tfoot>";
}
const content =
'<table class="table table-striped table-hover">' +
header +
footer +
"</table>";
$el.append(content);
// options that should be eval()ed
if (data.evalOptions)
$.each(data.evalOptions, function (i, x) {
/*jshint evil: true */
data.options[x] = indirectEval("(" + data.options[x] + ")");
});
// caseInsensitive searching? default true
const searchCI =
data.options === null ||
typeof data.options.search === "undefined" ||
data.options.search.caseInsensitive !== false;
const oTable = $(el)
.children("table")
.DataTable(
$.extend(
{
processing: true,
serverSide: true,
order: [],
orderClasses: false,
pageLength: 25,
ajax: {
url: data.action,
type: "POST",
data: function (d) {
d.search.caseInsensitive = searchCI;
d.escape = data.escape;
},
},
},
data.options
)
);
// the table object may need post-processing
if (typeof data.callback === "string") {
/*jshint evil: true */
const callback = indirectEval("(" + data.callback + ")");
if (typeof callback === "function") callback(oTable);
}
// use debouncing for searching boxes
$el
.find("label input")
.first()
.unbind("keyup")
.keyup(
debounce(data.searchDelay, function () {
oTable.search(this.value).draw();
})
);
const searchInputs = $el.find("tfoot input");
if (searchInputs.length > 0) {
// this is a little weird: aoColumns/bSearchable are still in DT 1.10
// https://github.com/DataTables/DataTables/issues/388
$.each(oTable.settings()[0].aoColumns, function (i, x) {
// hide the text box if not searchable
if (!x.bSearchable) searchInputs.eq(i as number).hide();
});
searchInputs.keyup(
debounce(data.searchDelay, function () {
oTable.column(searchInputs.index(this)).search(this.value).draw();
})
);
}
// FIXME: ugly scrollbars in tab panels b/c Bootstrap uses 'visible: auto'
$el.parents(".tab-content").css("overflow", "visible");
}
}
export { DatatableOutputBinding };

View File

@@ -0,0 +1,35 @@
import $ from "jquery";
import { OutputBinding } from "./OutputBinding";
class DownloadLinkOutputBinding extends OutputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find("a.shiny-download-link");
}
renderValue(el: HTMLElement, data: string): void {
$(el).attr("href", data);
}
}
interface FileDownloadEvent extends JQuery.Event {
name: string;
href: string;
}
// TODO-barret should this be in an init method?
// Trigger shiny:filedownload event whenever a downloadButton/Link is clicked
$(document).on(
"click.shinyDownloadLink",
"a.shiny-download-link",
function (e: Event) {
e;
const evt: FileDownloadEvent = jQuery.Event("shiny:filedownload");
evt.name = this.id;
evt.href = this.href;
$(document).trigger(evt);
}
);
export { DownloadLinkOutputBinding };

View File

@@ -0,0 +1,24 @@
import $ from "jquery";
import { OutputBinding } from "./OutputBinding";
import { shinyUnbindAll } from "../../shiny/initedMethods";
import { renderContent } from "../../shiny/render";
import type { errorsMessageValue } from "../../shiny/shinyapp";
class HtmlOutputBinding extends OutputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-html-output");
}
onValueError(el: HTMLElement, err: errorsMessageValue): void {
shinyUnbindAll(el);
this.renderError(el, err);
}
renderValue(
el: HTMLElement,
data: Parameters<typeof renderContent>[1]
): void {
renderContent(el, data);
}
}
export { HtmlOutputBinding };

View File

@@ -0,0 +1,296 @@
import $ from "jquery";
import { OutputBinding } from "./OutputBinding";
import {
createBrushHandler,
createClickHandler,
createClickInfo,
createHoverHandler,
disableDrag,
initCoordmap,
} from "../../imageutils";
import {
strToBool,
getComputedLinkColor,
getStyle,
hasOwnProperty,
} from "../../utils";
import { isIE, IEVersion } from "../../utils/browser";
import type { CoordmapInitType } from "../../imageutils/initCoordmap";
import type { errorsMessageValue } from "../../shiny/shinyapp";
class ImageOutputBinding extends OutputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-image-output, .shiny-plot-output");
}
renderValue(
el: HTMLElement,
data: {
coordmap: CoordmapInitType;
error?: string;
} & Record<string, string>
): void {
// The overall strategy:
// * Clear out existing image and event handlers.
// * Create new image.
// * Create various event handlers.
// * Bind those event handlers to events.
// * Insert the new image.
const outputId = this.getId(el);
const $el = $(el);
let img: HTMLImageElement;
// Get existing img element if present.
let $img = $el.find("img");
if ($img.length === 0) {
// If a img element is not already present, that means this is either
// the first time renderValue() has been called, or this is after an
// error.
img = document.createElement("img");
$el.append(img);
$img = $(img);
} else {
// Trigger custom 'reset' event for any existing images in the div
img = $img[0];
$img.trigger("reset");
}
if (!data) {
$el.empty();
return;
}
// If value is undefined, return alternate. Sort of like ||, except it won't
// return alternate for other falsy values (0, false, null).
function OR(value, alternate) {
if (value === undefined) return alternate;
return value;
}
const opts = {
clickId: $el.data("click-id"),
clickClip: OR(strToBool($el.data("click-clip")), true),
dblclickId: $el.data("dblclick-id"),
dblclickClip: OR(strToBool($el.data("dblclick-clip")), true),
dblclickDelay: OR($el.data("dblclick-delay"), 400),
hoverId: $el.data("hover-id"),
hoverClip: OR(strToBool($el.data("hover-clip")), true),
hoverDelayType: OR($el.data("hover-delay-type"), "debounce"),
hoverDelay: OR($el.data("hover-delay"), 300),
hoverNullOutside: OR(strToBool($el.data("hover-null-outside")), false),
brushId: $el.data("brush-id"),
brushClip: OR(strToBool($el.data("brush-clip")), true),
brushDelayType: OR($el.data("brush-delay-type"), "debounce"),
brushDelay: OR($el.data("brush-delay"), 300),
brushFill: OR($el.data("brush-fill"), "#666"),
brushStroke: OR($el.data("brush-stroke"), "#000"),
brushOpacity: OR($el.data("brush-opacity"), 0.3),
brushDirection: OR($el.data("brush-direction"), "xy"),
brushResetOnNew: OR(strToBool($el.data("brush-reset-on-new")), false),
coordmap: data.coordmap,
};
if (opts.brushFill === "auto") {
opts.brushFill = getComputedLinkColor($el[0]);
}
if (opts.brushStroke === "auto") {
opts.brushStroke = getStyle($el[0], "color");
}
// Copy items from data to img. Don't set the coordmap as an attribute.
$.each(data, function (key, value) {
if (value === null || key === "coordmap") {
return;
}
// this checks only against base64 encoded src values
// images put here are only from renderImage and renderPlot
if (key === "src" && value === img.getAttribute("src")) {
// Ensure the browser actually fires an onLoad event, which doesn't
// happen on WebKit if the value we set on src is the same as the
// value it already has
// https://github.com/rstudio/shiny/issues/2197
// https://stackoverflow.com/questions/5024111/javascript-image-onload-doesnt-fire-in-webkit-if-loading-same-image
img.removeAttribute("src");
}
img.setAttribute(key, value);
});
// Unset any attributes in the current img that were not provided in the
// new data.
for (let i = 0; i < img.attributes.length; i++) {
const attrib = img.attributes[i];
// Need to check attrib.specified on IE because img.attributes contains
// all possible attributes on IE.
if (attrib.specified && !hasOwnProperty(data, attrib.name)) {
img.removeAttribute(attrib.name);
}
}
if (!opts.coordmap) {
opts.coordmap = {
panels: [],
dims: {
// These values be set to the naturalWidth and naturalHeight once the image has loaded
height: null,
width: null,
},
};
}
// Remove event handlers that were added in previous runs of this function.
$el.off(".image_output");
$img.off(".image_output");
// When the image loads, initialize all the interaction handlers. When the
// value of src is set, the browser may not load the image immediately,
// even if it's a data URL. If we try to initialize this stuff
// immediately, it can cause problems because we use we need the raw image
// height and width
$img.off("load.shiny_image_interaction");
$img.one("load.shiny_image_interaction", function () {
// Use a local variable so the type check is happy
const optsCoordmap = (opts.coordmap = initCoordmap($el, opts.coordmap));
// This object listens for mousedowns, and triggers mousedown2 and dblclick2
// events as appropriate.
const clickInfo = createClickInfo(
$el,
opts.dblclickId,
opts.dblclickDelay
);
$el.on("mousedown.image_output", clickInfo.mousedown);
if (isIE() && IEVersion() === 8) {
$el.on("dblclick.image_output", clickInfo.dblclickIE8);
}
// ----------------------------------------------------------
// Register the various event handlers
// ----------------------------------------------------------
if (opts.clickId) {
disableDrag($el, $img);
const clickHandler = createClickHandler(
opts.clickId,
opts.clickClip,
optsCoordmap
);
$el.on("mousedown2.image_output", clickHandler.mousedown);
$el.on("resize.image_output", clickHandler.onResize);
// When img is reset, do housekeeping: clear $el's mouse listener and
// call the handler's onResetImg callback.
$img.on("reset.image_output", clickHandler.onResetImg);
}
if (opts.dblclickId) {
disableDrag($el, $img);
// We'll use the clickHandler's mousedown function, but register it to
// our custom 'dblclick2' event.
const dblclickHandler = createClickHandler(
opts.dblclickId,
opts.clickClip,
optsCoordmap
);
$el.on("dblclick2.image_output", dblclickHandler.mousedown);
$el.on("resize.image_output", dblclickHandler.onResize);
$img.on("reset.image_output", dblclickHandler.onResetImg);
}
if (opts.hoverId) {
disableDrag($el, $img);
const hoverHandler = createHoverHandler(
opts.hoverId,
opts.hoverDelay,
opts.hoverDelayType,
opts.hoverClip,
opts.hoverNullOutside,
optsCoordmap
);
$el.on("mousemove.image_output", hoverHandler.mousemove);
$el.on("mouseout.image_output", hoverHandler.mouseout);
$el.on("resize.image_output", hoverHandler.onResize);
$img.on("reset.image_output", hoverHandler.onResetImg);
}
if (opts.brushId) {
disableDrag($el, $img);
const brushHandler = createBrushHandler(
opts.brushId,
$el,
opts,
optsCoordmap,
outputId
);
$el.on("mousedown.image_output", brushHandler.mousedown);
$el.on("mousemove.image_output", brushHandler.mousemove);
$el.on("resize.image_output", brushHandler.onResize);
$img.on("reset.image_output", brushHandler.onResetImg);
}
if (opts.clickId || opts.dblclickId || opts.hoverId || opts.brushId) {
$el.addClass("crosshair");
}
if (data.error)
console.log("Error on server extracting coordmap: " + data.error);
});
}
renderError(el: HTMLElement, err: errorsMessageValue): void {
$(el).find("img").trigger("reset");
OutputBinding.prototype.renderError.call(this, el, err);
}
clearError(el: HTMLElement): void {
// Remove all elements except img and the brush; this is usually just
// error messages.
$(el)
.contents()
.filter(function () {
return !(
this instanceof HTMLElement &&
(this.tagName === "IMG" || this.id === el.id + "_brush")
);
})
.remove();
// TODO-barret does this work?: `super.clearError(el)`
OutputBinding.prototype.clearError.call(this, el);
}
resize(
el: HTMLElement,
width: string | number,
height: string | number
): void {
$(el).find("img").trigger("resize");
return;
width;
height;
}
}
const imageOutputBinding = new ImageOutputBinding();
export { imageOutputBinding, ImageOutputBinding };

View File

@@ -0,0 +1,31 @@
import { TextOutputBinding } from "./text";
import { BindingRegistry } from "../registry";
import { DownloadLinkOutputBinding } from "./downloadlink";
import { DatatableOutputBinding } from "./datatable";
import { HtmlOutputBinding } from "./html";
import { imageOutputBinding } from "./image";
import { OutputBinding } from "./OutputBinding";
type InitOutputBindings = {
outputBindings: BindingRegistry<OutputBinding>;
};
function initOutputBindings(): InitOutputBindings {
const outputBindings = new BindingRegistry<OutputBinding>();
outputBindings.register(new TextOutputBinding(), "shiny.textOutput");
outputBindings.register(
new DownloadLinkOutputBinding(),
"shiny.downloadLink"
);
outputBindings.register(
new DatatableOutputBinding(),
"shiny.datatableOutput"
);
outputBindings.register(new HtmlOutputBinding(), "shiny.htmlOutput");
outputBindings.register(imageOutputBinding, "shiny.imageOutput");
return { outputBindings };
}
export { OutputBinding, initOutputBindings };

View File

@@ -0,0 +1,13 @@
import $ from "jquery";
import { OutputBinding } from "./OutputBinding";
class TextOutputBinding extends OutputBinding {
find(scope: HTMLElement): JQuery<HTMLElement> {
return $(scope).find(".shiny-text-output");
}
renderValue(el: HTMLElement, data: string | number | boolean): void {
$(el).text(data);
}
}
export { TextOutputBinding };

View File

@@ -0,0 +1,47 @@
import type { errorsMessageValue } from "../shiny/shinyapp";
import { makeResizeFilter } from "../utils";
import { OutputBinding } from "./output";
interface OutpuBindingWithResize extends OutputBinding {
resize?: (
el: HTMLElement,
width: string | number,
height: string | number
) => void;
}
class OutputBindingAdapter {
el: HTMLElement;
binding: OutputBinding;
constructor(el: HTMLElement, binding: OutpuBindingWithResize) {
this.el = el;
this.binding = binding;
// If the binding actually has a resize method, override the prototype of
// onResize with a version that does a makeResizeFilter on the element.
if (binding.resize) {
this.onResize = makeResizeFilter(el, function (width, height) {
binding.resize(el, width, height);
});
}
}
getId(): string {
return this.binding.getId(this.el);
}
onValueChange(data: unknown): void {
this.binding.onValueChange(this.el, data);
}
onValueError(err: errorsMessageValue): void {
this.binding.onValueError(this.el, err);
}
showProgress(show: boolean): void {
this.binding.showProgress(this.el, show);
}
onResize(): void {
// Intentionally left blank; see constructor
}
}
export { OutputBindingAdapter };

View File

@@ -0,0 +1,51 @@
import { mergeSort } from "../utils";
interface BindingInterface {
name: string;
}
interface BindingObjType<BindingType> {
binding: BindingType;
priority: number;
name?: string;
}
class BindingRegistry<BindingType extends BindingInterface> {
bindings: Array<BindingObjType<BindingType>> = [];
bindingNames: Record<string, BindingObjType<BindingType>> = {};
register(binding: BindingType, bindingName: string, priority = 0): void {
const bindingObj = { binding, priority };
this.bindings.unshift(bindingObj);
if (bindingName) {
this.bindingNames[bindingName] = bindingObj;
binding.name = bindingName;
}
}
setPriority(bindingName: string, priority: number): void {
const bindingObj = this.bindingNames[bindingName];
if (!bindingObj)
throw "Tried to set priority on unknown binding " + bindingName;
bindingObj.priority = priority || 0;
}
getPriority(bindingName: string): number | false {
const bindingObj = this.bindingNames[bindingName];
if (!bindingObj) return false;
return bindingObj.priority;
}
getBindings(): Array<BindingObjType<BindingType>> {
// Sort the bindings. The ones with higher priority are consulted
// first; ties are broken by most-recently-registered.
return mergeSort(this.bindings, function (a, b) {
return b.priority - a.priority;
});
}
}
export { BindingRegistry };

View File

@@ -0,0 +1,40 @@
import type { InputBinding } from "../bindings/input/InputBinding";
import type { OutputBindingAdapter } from "../bindings/output_adapter";
import type { priorityType } from "../inputPolicies/InputPolicy";
import type { errorsMessageValue } from "../shiny/shinyapp";
interface ShinyEventCommon extends JQuery.Event {
name: string;
value: unknown;
el: HTMLElement;
}
interface ShinyEventInputChanged extends ShinyEventCommon {
value: unknown;
binding: InputBinding;
inputType: string;
priority: priorityType;
}
interface ShinyEventUpdateInput extends ShinyEventCommon {
message: unknown;
binding: InputBinding;
}
interface ShinyEventValue extends ShinyEventCommon {
value: unknown;
binding: OutputBindingAdapter;
}
interface ShinyEventError extends ShinyEventCommon {
binding: OutputBindingAdapter;
error: errorsMessageValue;
}
interface ShinyEventMessage extends JQuery.Event {
message: Record<string, unknown>;
}
export type {
ShinyEventInputChanged,
ShinyEventUpdateInput,
ShinyEventValue,
ShinyEventError,
ShinyEventMessage,
};

View File

@@ -0,0 +1,26 @@
import $ from "jquery";
import type { FileInputBinding } from "../bindings/input/fileinput";
import type { ShinyEventInputChanged } from "./shinyEvents";
function triggerFileInputChanged(
name: string,
value: unknown,
binding: FileInputBinding,
el: HTMLElement,
inputType: string,
onEl: typeof document
): ShinyEventInputChanged {
const evt = $.Event("shiny:inputchanged") as ShinyEventInputChanged;
evt.name = name;
evt.value = value;
evt.binding = binding;
evt.el = el;
evt.inputType = inputType;
$(onEl).trigger(evt);
return evt;
}
export { triggerFileInputChanged };

View File

@@ -1,45 +1,61 @@
import $ from "jquery";
import { triggerFileInputChanged } from "../events/shiny_inputchanged";
import { $escape } from "../utils";
import { ShinyApp } from "../shiny/shinyapp";
import { getFileInputBinding } from "../shiny/initedMethods";
type JobId = string;
type UploadUrl = string;
type UploadInitValue = { jobId: JobId; uploadUrl: UploadUrl };
type UploadEndValue = never;
// Generic driver class for doing chunk-wise asynchronous processing of a
// FileList object. Subclass/clone it and override the `on*` functions to
// make it do something useful.
const FileProcessor = function (files) {
this.files = files;
this.fileIndex = -1;
class FileProcessor {
files: FileList;
fileIndex = -1;
// Currently need to use small chunk size because R-Websockets can't
// handle continuation frames
this.aborted = false;
this.completed = false;
aborted = false;
completed = false;
// TODO: Register error/abort callbacks
constructor(files: FileList, exec$run = true) {
this.files = files;
this.$run();
};
// TODO: Register error/abort callbacks
if (exec$run) {
this.$run();
}
}
(function () {
// Begin callbacks. Subclassers/cloners may override any or all of these.
this.onBegin = function (files, cont) {
onBegin(files: FileList, cont: () => void): void {
files;
setTimeout(cont, 0);
};
this.onFile = function (file, cont) {
}
onFile(file: File, cont: () => void): void {
file;
setTimeout(cont, 0);
};
this.onComplete = function () {
}
onComplete(): void {
return;
};
this.onAbort = function () {
}
onAbort(): void {
return;
};
}
// End callbacks
// Aborts processing, unless it's already completed
this.abort = function () {
abort(): void {
if (this.completed || this.aborted) return;
this.aborted = true;
this.onAbort();
};
}
// Returns a bound function that will call this.$run one time.
this.$getRun = function () {
$getRun(): () => void {
let called = false;
return () => {
@@ -47,11 +63,11 @@ const FileProcessor = function (files) {
called = true;
this.$run();
};
};
}
// This function will be called multiple times to advance the process.
// It relies on the state of the object's fields to know what to do next.
this.$run = function () {
$run(): void {
if (this.aborted || this.completed) return;
if (this.fileIndex < 0) {
@@ -75,7 +91,202 @@ const FileProcessor = function (files) {
const file = this.files[this.fileIndex++];
this.onFile(file, this.$getRun());
};
}.call(FileProcessor.prototype));
}
}
export { FileProcessor };
class FileUploader extends FileProcessor {
shinyapp: ShinyApp;
id: string;
el: HTMLElement;
jobId: JobId;
uploadUrl: UploadUrl;
progressBytes: number;
totalBytes: number;
constructor(
shinyapp: ShinyApp,
id: string,
files: FileList,
el: HTMLElement
) {
// Init super with files, do not execute `this.$run()` before setting variables
super(files, false);
this.shinyapp = shinyapp;
this.id = id;
this.el = el;
this.$run();
}
makeRequest(
method: "uploadInit",
args: Array<Array<{ name: string; size: number; type: string }>>,
onSuccess: (value: UploadInitValue) => void,
onFailure: Parameters<ShinyApp["makeRequest"]>[3],
blobs: Parameters<ShinyApp["makeRequest"]>[4]
): void;
makeRequest(
method: "uploadEnd",
args: [string, string],
// UploadEndValue can not be used as the type will not conform
onSuccess: (value: unknown) => void,
onFailure: Parameters<ShinyApp["makeRequest"]>[3],
blobs: Parameters<ShinyApp["makeRequest"]>[4]
): void;
makeRequest(
method: string,
args: Array<unknown>,
onSuccess: Parameters<ShinyApp["makeRequest"]>[2],
onFailure: Parameters<ShinyApp["makeRequest"]>[3],
blobs: Parameters<ShinyApp["makeRequest"]>[4]
): void {
this.shinyapp.makeRequest(method, args, onSuccess, onFailure, blobs);
}
onBegin(files: FileList, cont: () => void): void {
// Reset progress bar
this.$setError(null);
this.$setActive(true);
this.$setVisible(true);
this.onProgress(null, 0);
this.totalBytes = 0;
this.progressBytes = 0;
$.each(files, (i, file) => {
this.totalBytes += file.size;
});
const fileInfo = $.map(files, function (file: File, i) {
return {
name: file.name,
size: file.size,
type: file.type,
};
i;
});
this.makeRequest(
"uploadInit",
[fileInfo],
(response) => {
this.jobId = response.jobId;
this.uploadUrl = response.uploadUrl;
cont();
},
(error) => {
this.onError(error);
},
undefined
);
}
onFile(file: File, cont: () => void): void {
this.onProgress(file, 0);
$.ajax(this.uploadUrl, {
type: "POST",
cache: false,
xhr: () => {
const xhrVal = $.ajaxSettings.xhr();
if (xhrVal.upload) {
xhrVal.upload.onprogress = (e) => {
if (e.lengthComputable) {
this.onProgress(
file,
(this.progressBytes + e.loaded) / this.totalBytes
);
}
};
}
return xhrVal;
},
data: file,
contentType: "application/octet-stream",
processData: false,
success: () => {
this.progressBytes += file.size;
cont();
},
error: (jqXHR, textStatus, errorThrown) => {
errorThrown;
this.onError(jqXHR.responseText || textStatus);
},
});
}
onComplete(): void {
const fileInfo = $.map(this.files, function (file: File, i) {
return {
name: file.name,
size: file.size,
type: file.type,
};
i;
});
// Trigger shiny:inputchanged. Unlike a normal shiny:inputchanged event,
// it's not possible to modify the information before the values get
// sent to the server.
const evt = triggerFileInputChanged(
this.id,
fileInfo,
getFileInputBinding(),
this.el,
"shiny.fileupload",
document
);
this.makeRequest(
"uploadEnd",
[this.jobId, this.id],
() => {
this.$setActive(false);
this.onProgress(null, 1);
this.$bar().text("Upload complete");
// Reset the file input's value to "". This allows the same file to be
// uploaded again. https://stackoverflow.com/a/22521275
$(evt.el).val("");
},
(error) => {
this.onError(error);
},
undefined
);
this.$bar().text("Finishing upload");
}
onError(message: string): void {
this.$setError(message || "");
this.$setActive(false);
}
onAbort(): void {
this.$setVisible(false);
}
onProgress(file: File | null, completed: number): void {
this.$bar().width(Math.round(completed * 100) + "%");
this.$bar().text(file ? file.name : "");
}
$container(): JQuery<HTMLElement> {
return $("#" + $escape(this.id) + "_progress.shiny-file-input-progress");
}
$bar(): JQuery<HTMLElement> {
return $(
"#" +
$escape(this.id) +
"_progress.shiny-file-input-progress .progress-bar"
);
}
$setVisible(visible: boolean): void {
this.$container().css("visibility", visible ? "visible" : "hidden");
}
$setError(error: string | null): void {
this.$bar().toggleClass("progress-bar-danger", error !== null);
if (error !== null) {
this.onProgress(null, 1);
this.$bar().text(error);
}
}
$setActive(active: boolean): void {
this.$container().toggleClass("active", !!active);
}
}
export { FileUploader };
export type { UploadInitValue, UploadEndValue };

View File

@@ -0,0 +1,641 @@
import $ from "jquery";
import type { CoordmapType } from "./initCoordmap";
import { findOrigin } from "./initCoordmap";
import { equal, isnan, mapValues, roundSignif } from "../utils";
import type { PanelType } from "./initPanelScales";
import type { OffsetType } from "./findbox";
import { findBox } from "./findbox";
import { shiftToRange } from "./shiftToRange";
type BoundsType = {
xmin: number;
xmax: number;
ymin: number;
ymax: number;
};
type BoundsCss = BoundsType;
type BoundsData = BoundsType;
type ImageState = {
brushing?: boolean;
dragging?: boolean;
resizing?: boolean;
// Offset of last mouse down and up events (in CSS pixels)
down?: OffsetType;
up?: OffsetType;
// Which side(s) we're currently resizing
resizeSides?: {
left: boolean;
right: boolean;
top: boolean;
bottom: boolean;
};
boundsCss?: BoundsCss;
boundsData?: BoundsData;
// Panel object that the brush is in
panel?: PanelType;
// The bounds at the start of a drag/resize (in CSS pixels)
changeStartBounds?: BoundsType;
};
type BrushOptsType = {
brushDirection: "x" | "y" | "xy";
brushClip: boolean;
brushFill: string;
brushOpacity: string;
brushStroke: string;
brushDelayType?: "throttle" | "debounce";
brushDelay?: number;
brushResetOnNew?: boolean;
};
type BrushType = {
reset: () => void;
importOldBrush: () => void;
isInsideBrush: (offsetCss: OffsetType) => boolean;
isInResizeArea: (offsetCss: OffsetType) => boolean;
whichResizeSides: (offsetCss: OffsetType) => ImageState["resizeSides"];
// A callback when the wrapper div or img is resized.
onResize: () => void;
// TODO define this type as both a getter and a setter interfaces.
// boundsCss: (boxCss: BoundsCss) => void;
// boundsCss: () => BoundsCss;
boundsCss: {
(boxCss: BoundsCss): void;
(): BoundsCss;
};
boundsData: {
(boxData: BoundsData): void;
(): BoundsData;
};
getPanel: () => ImageState["panel"];
down: {
(): ImageState["down"];
(offsetCss): void;
};
up: {
(): ImageState["up"];
(offsetCss): void;
};
isBrushing: () => ImageState["brushing"];
startBrushing: () => void;
brushTo: (offsetCss: OffsetType) => void;
stopBrushing: () => void;
isDragging: () => ImageState["dragging"];
startDragging: () => void;
dragTo: (offsetCss: OffsetType) => void;
stopDragging: () => void;
isResizing: () => ImageState["resizing"];
startResizing: () => void;
resizeTo: (offsetCss: OffsetType) => void;
stopResizing: () => void;
};
// Returns an object that represents the state of the brush. This gets wrapped
// in a brushHandler, which provides various event listeners.
function createBrush(
$el: JQuery<HTMLElement>,
opts: BrushOptsType,
coordmap: CoordmapType,
expandPixels: number
): BrushType {
// Number of pixels outside of brush to allow start resizing
const resizeExpand = 10;
const el = $el[0];
let $div = null; // The div representing the brush
const state: ImageState = {};
// Aliases for conciseness
const cssToImg = coordmap.scaleCssToImg;
const imgToCss = coordmap.scaleImgToCss;
reset();
function reset() {
// Current brushing/dragging/resizing state
state.brushing = false;
state.dragging = false;
state.resizing = false;
// Offset of last mouse down and up events (in CSS pixels)
state.down = { x: NaN, y: NaN };
state.up = { x: NaN, y: NaN };
// Which side(s) we're currently resizing
state.resizeSides = {
left: false,
right: false,
top: false,
bottom: false,
};
// Bounding rectangle of the brush, in CSS pixel and data dimensions. We
// need to record data dimensions along with pixel dimensions so that when
// a new plot is sent, we can re-draw the brush div with the appropriate
// coords.
state.boundsCss = {
xmin: NaN,
xmax: NaN,
ymin: NaN,
ymax: NaN,
};
state.boundsData = {
xmin: NaN,
xmax: NaN,
ymin: NaN,
ymax: NaN,
};
// Panel object that the brush is in
state.panel = null;
// The bounds at the start of a drag/resize (in CSS pixels)
state.changeStartBounds = {
xmin: NaN,
xmax: NaN,
ymin: NaN,
ymax: NaN,
};
if ($div) $div.remove();
}
// If there's an existing brush div, use that div to set the new brush's
// settings, provided that the x, y, and panel variables have the same names,
// and there's a panel with matching panel variable values.
function importOldBrush() {
const oldDiv = $el.find("#" + el.id + "_brush");
if (oldDiv.length === 0) return;
const oldBoundsData = oldDiv.data("bounds-data");
const oldPanel = oldDiv.data("panel");
if (!oldBoundsData || !oldPanel) return;
// Find a panel that has matching vars; if none found, we can't restore.
// The oldPanel and new panel must match on their mapping vars, and the
// values.
for (let i = 0; i < coordmap.panels.length; i++) {
const curPanel = coordmap.panels[i];
if (
equal(oldPanel.mapping, curPanel.mapping) &&
equal(oldPanel.panel_vars, curPanel.panel_vars)
) {
// We've found a matching panel
state.panel = coordmap.panels[i];
break;
}
}
// If we didn't find a matching panel, remove the old div and return
if (state.panel === null) {
oldDiv.remove();
return;
}
$div = oldDiv;
boundsData(oldBoundsData);
updateDiv();
}
// This will reposition the brush div when the image is resized, maintaining
// the same data coordinates. Note that the "resize" here refers to the
// wrapper div/img being resized; elsewhere, "resize" refers to the brush
// div being resized.
function onResize() {
const boundsDataVal = boundsData();
// Check to see if we have valid boundsData
for (const val in boundsDataVal) {
if (isnan(boundsDataVal[val])) return;
}
boundsData(boundsDataVal);
updateDiv();
}
// Return true if the offset is inside min/max coords
function isInsideBrush(offsetCss) {
const bounds = state.boundsCss;
return (
offsetCss.x <= bounds.xmax &&
offsetCss.x >= bounds.xmin &&
offsetCss.y <= bounds.ymax &&
offsetCss.y >= bounds.ymin
);
}
// Return true if offset is inside a region to start a resize
function isInResizeArea(offsetCss) {
const sides = whichResizeSides(offsetCss);
return sides.left || sides.right || sides.top || sides.bottom;
}
// Return an object representing which resize region(s) the cursor is in.
function whichResizeSides(offsetCss) {
const b = state.boundsCss;
// Bounds with expansion
const e = {
xmin: b.xmin - resizeExpand,
xmax: b.xmax + resizeExpand,
ymin: b.ymin - resizeExpand,
ymax: b.ymax + resizeExpand,
};
const res = {
left: false,
right: false,
top: false,
bottom: false,
};
if (
(opts.brushDirection === "xy" || opts.brushDirection === "x") &&
offsetCss.y <= e.ymax &&
offsetCss.y >= e.ymin
) {
if (offsetCss.x < b.xmin && offsetCss.x >= e.xmin) res.left = true;
else if (offsetCss.x > b.xmax && offsetCss.x <= e.xmax) res.right = true;
}
if (
(opts.brushDirection === "xy" || opts.brushDirection === "y") &&
offsetCss.x <= e.xmax &&
offsetCss.x >= e.xmin
) {
if (offsetCss.y < b.ymin && offsetCss.y >= e.ymin) res.top = true;
else if (offsetCss.y > b.ymax && offsetCss.y <= e.ymax) res.bottom = true;
}
return res;
}
// Sets the bounds of the brush (in CSS pixels), given a box and optional
// panel. This will fit the box bounds into the panel, so we don't brush
// outside of it. This knows whether we're brushing in the x, y, or xy
// directions, and sets bounds accordingly. If no box is passed in, just
// return current bounds.
function boundsCss(): ImageState["boundsCss"];
function boundsCss(boxCss: BoundsCss): void;
function boundsCss(boxCss?: BoundsCss) {
if (boxCss === undefined) {
return $.extend({}, state.boundsCss);
}
let minCss = { x: boxCss.xmin, y: boxCss.ymin };
let maxCss = { x: boxCss.xmax, y: boxCss.ymax };
const panel = state.panel;
const panelBoundsImg = panel.range;
if (opts.brushClip) {
minCss = imgToCss(panel.clipImg(cssToImg(minCss)));
maxCss = imgToCss(panel.clipImg(cssToImg(maxCss)));
}
if (opts.brushDirection === "xy") {
// No change
} else if (opts.brushDirection === "x") {
// Extend top and bottom of plotting area
minCss.y = imgToCss({ y: panelBoundsImg.top }).y;
maxCss.y = imgToCss({ y: panelBoundsImg.bottom }).y;
} else if (opts.brushDirection === "y") {
minCss.x = imgToCss({ x: panelBoundsImg.left }).x;
maxCss.x = imgToCss({ x: panelBoundsImg.right }).x;
}
state.boundsCss = {
xmin: minCss.x,
xmax: maxCss.x,
ymin: minCss.y,
ymax: maxCss.y,
};
// Positions in data space
const minData = state.panel.scaleImgToData(cssToImg(minCss));
const maxData = state.panel.scaleImgToData(cssToImg(maxCss));
// For reversed scales, the min and max can be reversed, so use findBox
// to ensure correct order.
state.boundsData = findBox(minData, maxData);
// Round to 14 significant digits to avoid spurious changes in FP values
// (#1634).
state.boundsData = mapValues(state.boundsData, (val) =>
roundSignif(val, 14)
) as BoundsData;
// We also need to attach the data bounds and panel as data attributes, so
// that if the image is re-sent, we can grab the data bounds to create a new
// brush. This should be fast because it doesn't actually modify the DOM.
$div.data("bounds-data", state.boundsData);
$div.data("panel", state.panel);
return undefined;
}
// Get or set the bounds of the brush using coordinates in the data space.
function boundsData(): ImageState["boundsData"];
function boundsData(
boxData: Parameters<PanelType["scaleDataToImg"]>[0]
): void;
function boundsData(boxData?: Parameters<PanelType["scaleDataToImg"]>[0]) {
if (boxData === undefined) {
return $.extend({}, state.boundsData);
}
let boxCss = imgToCss(state.panel.scaleDataToImg(boxData));
// Round to 13 significant digits to avoid spurious changes in FP values
// (#2197).
boxCss = mapValues(boxCss, (val) => roundSignif(val, 13));
// The scaling function can reverse the direction of the axes, so we need to
// find the min and max again.
boundsCss({
xmin: Math.min(boxCss.xmin, boxCss.xmax),
xmax: Math.max(boxCss.xmin, boxCss.xmax),
ymin: Math.min(boxCss.ymin, boxCss.ymax),
ymax: Math.max(boxCss.ymin, boxCss.ymax),
});
return undefined;
}
function getPanel() {
return state.panel;
}
// Add a new div representing the brush.
function addDiv() {
if ($div) $div.remove();
// Start hidden; we'll show it when movement occurs
$div = $(document.createElement("div"))
.attr("id", el.id + "_brush")
.css({
"background-color": opts.brushFill,
opacity: opts.brushOpacity,
"pointer-events": "none",
position: "absolute",
})
.hide();
const borderStyle = "1px solid " + opts.brushStroke;
if (opts.brushDirection === "xy") {
$div.css({
border: borderStyle,
});
} else if (opts.brushDirection === "x") {
$div.css({
"border-left": borderStyle,
"border-right": borderStyle,
});
} else if (opts.brushDirection === "y") {
$div.css({
"border-top": borderStyle,
"border-bottom": borderStyle,
});
}
$el.append($div);
$div.offset({ x: 0, y: 0 }).width(0).outerHeight(0);
}
// Update the brush div to reflect the current brush bounds.
function updateDiv() {
// Need parent offset relative to page to calculate mouse offset
// relative to page.
const imgOffsetCss = findOrigin($el.find("img"));
const b = state.boundsCss;
$div
.offset({
top: imgOffsetCss.y + b.ymin,
left: imgOffsetCss.x + b.xmin,
})
.outerWidth(b.xmax - b.xmin + 1)
.outerHeight(b.ymax - b.ymin + 1);
}
function down(offsetCss?: OffsetType) {
if (offsetCss === undefined) return state.down;
state.down = offsetCss;
return undefined;
}
function up(offsetCss?: OffsetType) {
if (offsetCss === undefined) return state.up;
state.up = offsetCss;
return undefined;
}
function isBrushing() {
return state.brushing;
}
function startBrushing() {
state.brushing = true;
addDiv();
state.panel = coordmap.getPanelCss(state.down, expandPixels);
boundsCss(findBox(state.down, state.down));
updateDiv();
}
function brushTo(offsetCss: OffsetType) {
boundsCss(findBox(state.down, offsetCss));
$div.show();
updateDiv();
}
function stopBrushing() {
state.brushing = false;
// Save the final bounding box of the brush
boundsCss(findBox(state.down, state.up));
}
function isDragging() {
return state.dragging;
}
function startDragging() {
state.dragging = true;
state.changeStartBounds = $.extend({}, state.boundsCss);
}
function dragTo(offsetCss: OffsetType) {
// How far the brush was dragged
const dx = offsetCss.x - state.down.x;
const dy = offsetCss.y - state.down.y;
// Calculate what new positions would be, before clipping.
const start = state.changeStartBounds;
let newBoundsCss = {
xmin: start.xmin + dx,
xmax: start.xmax + dx,
ymin: start.ymin + dy,
ymax: start.ymax + dy,
};
// Clip to the plotting area
if (opts.brushClip) {
const panelBoundsImg = state.panel.range;
const newBoundsImg = cssToImg(newBoundsCss);
// Convert to format for shiftToRange
let xvalsImg = [newBoundsImg.xmin, newBoundsImg.xmax];
let yvalsImg = [newBoundsImg.ymin, newBoundsImg.ymax];
xvalsImg = shiftToRange(
xvalsImg,
panelBoundsImg.left,
panelBoundsImg.right
);
yvalsImg = shiftToRange(
yvalsImg,
panelBoundsImg.top,
panelBoundsImg.bottom
);
// Convert back to bounds format
newBoundsCss = imgToCss({
xmin: xvalsImg[0],
xmax: xvalsImg[1],
ymin: yvalsImg[0],
ymax: yvalsImg[1],
});
}
boundsCss(newBoundsCss);
updateDiv();
}
function stopDragging() {
state.dragging = false;
}
function isResizing() {
return state.resizing;
}
function startResizing() {
state.resizing = true;
state.changeStartBounds = $.extend({}, state.boundsCss);
state.resizeSides = whichResizeSides(state.down);
}
function resizeTo(offsetCss: OffsetType) {
// How far the brush was dragged
const dCss = {
x: offsetCss.x - state.down.x,
y: offsetCss.y - state.down.y,
};
const dImg = cssToImg(dCss);
// Calculate what new positions would be, before clipping.
const bImg = cssToImg(state.changeStartBounds);
const panelBoundsImg = state.panel.range;
if (state.resizeSides.left) {
const xminImg = shiftToRange(
bImg.xmin + dImg.x,
panelBoundsImg.left,
bImg.xmax
)[0];
bImg.xmin = xminImg;
} else if (state.resizeSides.right) {
const xmaxImg = shiftToRange(
bImg.xmax + dImg.x,
bImg.xmin,
panelBoundsImg.right
)[0];
bImg.xmax = xmaxImg;
}
if (state.resizeSides.top) {
const yminImg = shiftToRange(
bImg.ymin + dImg.y,
panelBoundsImg.top,
bImg.ymax
)[0];
bImg.ymin = yminImg;
} else if (state.resizeSides.bottom) {
const ymaxImg = shiftToRange(
bImg.ymax + dImg.y,
bImg.ymin,
panelBoundsImg.bottom
)[0];
bImg.ymax = ymaxImg;
}
boundsCss(imgToCss(bImg));
updateDiv();
}
function stopResizing() {
state.resizing = false;
}
return {
reset: reset,
importOldBrush: importOldBrush,
isInsideBrush: isInsideBrush,
isInResizeArea: isInResizeArea,
whichResizeSides: whichResizeSides,
onResize: onResize, // A callback when the wrapper div or img is resized.
boundsCss: boundsCss,
boundsData: boundsData,
getPanel: getPanel,
down: down,
up: up,
isBrushing: isBrushing,
startBrushing: startBrushing,
brushTo: brushTo,
stopBrushing: stopBrushing,
isDragging: isDragging,
startDragging: startDragging,
dragTo: dragTo,
stopDragging: stopDragging,
isResizing: isResizing,
startResizing: startResizing,
resizeTo: resizeTo,
stopResizing: stopResizing,
};
}
export { createBrush };
export type { BoundsType, BrushOptsType, BoundsCss };

View File

@@ -0,0 +1,106 @@
import $ from "jquery";
// This object provides two public event listeners: mousedown, and
// dblclickIE8.
// We need to make sure that, when the image is listening for double-
// clicks, that a double-click doesn't trigger two click events. We'll
// trigger custom mousedown2 and dblclick2 events with this mousedown
// listener.
function createClickInfo(
$el: JQuery<HTMLElement>,
dblclickId: string,
dblclickDelay: number
): {
mousedown: (e: JQuery.MouseDownEvent) => void;
dblclickIE8: (e: JQuery.DoubleClickEvent) => void;
} {
let clickTimer = null;
let pendingE: JQuery.MouseDownEvent = null; // A pending mousedown2 event
// Create a new event of type eventType (like 'mousedown2'), and trigger
// it with the information stored in this.e.
function triggerEvent(
newEventType: string,
e: JQuery.MouseDownEvent | JQuery.DoubleClickEvent
) {
// Extract important info from e and construct a new event with type
// eventType.
const e2 = $.Event(newEventType, {
which: e.which,
pageX: e.pageX,
pageY: e.pageY,
});
$el.trigger(e2);
}
function triggerPendingMousedown2() {
// It's possible that between the scheduling of a mousedown2 and the
// time this callback is executed, someone else triggers a
// mousedown2, so check for that.
if (pendingE) {
triggerEvent("mousedown2", pendingE);
pendingE = null;
}
}
// Set a timer to trigger a mousedown2 event, using information from the
// last recorded mousdown event.
function scheduleMousedown2(e: JQuery.MouseDownEvent) {
pendingE = e;
clickTimer = setTimeout(function () {
triggerPendingMousedown2();
}, dblclickDelay);
}
function mousedown(e: JQuery.MouseDownEvent) {
// Listen for left mouse button only
if (e.which !== 1) return;
// If no dblclick listener, immediately trigger a mousedown2 event.
if (!dblclickId) {
triggerEvent("mousedown2", e);
return;
}
// If there's a dblclick listener, make sure not to count this as a
// click on the first mousedown; we need to wait for the dblclick
// delay before we can be sure this click was a single-click.
if (pendingE === null) {
scheduleMousedown2(e);
} else {
clearTimeout(clickTimer);
// If second click is too far away, it doesn't count as a double
// click. Instead, immediately trigger a mousedown2 for the previous
// click, and set this click as a new first click.
if (
(pendingE && Math.abs(pendingE.pageX - e.pageX) > 2) ||
Math.abs(pendingE.pageY - e.pageY) > 2
) {
triggerPendingMousedown2();
scheduleMousedown2(e);
} else {
// The second click was close to the first one. If it happened
// within specified delay, trigger our custom 'dblclick2' event.
pendingE = null;
triggerEvent("dblclick2", e);
}
}
}
// IE8 needs a special hack because when you do a double-click it doesn't
// trigger the click event twice - it directly triggers dblclick.
function dblclickIE8(e: JQuery.DoubleClickEvent) {
e.which = 1; // In IE8, e.which is 0 instead of 1. ???
triggerEvent("dblclick2", e);
}
return {
mousedown: mousedown,
dblclickIE8: dblclickIE8,
};
}
export { createClickInfo };

View File

@@ -0,0 +1,418 @@
import $ from "jquery";
import { imageOutputBinding } from "../bindings/output/image";
import { shinySetInputValue } from "../shiny/initedMethods";
import { Debouncer, Throttler } from "../time";
import { createBrush } from "./createBrush";
import type { BoundsCss, BoundsType, BrushOptsType } from "./createBrush";
import type { OffsetType } from "./findbox";
import type { CoordmapType } from "./initCoordmap";
import type { PanelType } from "./initPanelScales";
// ----------------------------------------------------------
// Handler creators for click, hover, brush.
// Each of these returns an object with a few public members. These public
// members are callbacks that are meant to be bound to events on $el with
// the same name (like 'mousedown').
// ----------------------------------------------------------
type CreateHandlerType = {
mousemove?: (e: JQuery.MouseMoveEvent) => void;
mouseout?: (e: JQuery.MouseOutEvent) => void;
mousedown?: (e: JQuery.MouseDownEvent) => void;
onResetImg: () => void;
onResize?: () => void;
};
type BrushInfo = {
xmin: number;
xmax: number;
ymin: number;
ymax: number;
// eslint-disable-next-line camelcase
coords_css?: BoundsCss;
// eslint-disable-next-line camelcase
coords_img?: BoundsType;
x?: number;
y?: number;
// eslint-disable-next-line camelcase
img_css_ratio?: OffsetType;
mapping?: PanelType["mapping"];
domain?: PanelType["domain"];
range?: PanelType["range"];
log?: PanelType["log"];
direction?: BrushOptsType["brushDirection"];
brushId?: string;
outputId?: string;
};
type InputIdType = Parameters<CoordmapType["mouseCoordinateSender"]>[0];
type ClipType = Parameters<CoordmapType["mouseCoordinateSender"]>[1];
type NullOutsideType = Parameters<CoordmapType["mouseCoordinateSender"]>[2];
function createClickHandler(
inputId: InputIdType,
clip: ClipType,
coordmap: CoordmapType
): CreateHandlerType {
const clickInfoSender = coordmap.mouseCoordinateSender(inputId, clip);
return {
mousedown: function (e) {
// Listen for left mouse button only
if (e.which !== 1) return;
clickInfoSender(e);
},
onResetImg: function () {
clickInfoSender(null);
},
onResize: null,
};
}
function createHoverHandler(
inputId: InputIdType,
delay: number,
delayType: "throttle" | string,
clip: ClipType,
nullOutside: NullOutsideType,
coordmap: CoordmapType
): CreateHandlerType {
const sendHoverInfo = coordmap.mouseCoordinateSender(
inputId,
clip,
nullOutside
);
let hoverInfoSender: Throttler | Debouncer;
if (delayType === "throttle")
hoverInfoSender = new Throttler(null, sendHoverInfo, delay);
else hoverInfoSender = new Debouncer(null, sendHoverInfo, delay);
// What to do when mouse exits the image
let mouseout: () => void;
if (nullOutside)
mouseout = function () {
hoverInfoSender.normalCall(null);
};
else
mouseout = function () {
// do nothing
};
return {
mousemove: function (e) {
hoverInfoSender.normalCall(e);
},
mouseout: mouseout,
onResetImg: function () {
hoverInfoSender.immediateCall(null);
},
onResize: null,
};
}
// Returns a brush handler object. This has three public functions:
// mousedown, mousemove, and onResetImg.
function createBrushHandler(
inputId: InputIdType,
$el: JQuery<HTMLElement>,
opts: BrushOptsType,
coordmap: CoordmapType,
outputId: BrushInfo["outputId"]
): CreateHandlerType {
// Parameter: expand the area in which a brush can be started, by this
// many pixels in all directions. (This should probably be a brush option)
const expandPixels = 20;
// Represents the state of the brush
const brush = createBrush($el, opts, coordmap, expandPixels);
// Brush IDs can span multiple image/plot outputs. When an output is brushed,
// if a brush with the same ID is active on a different image/plot, it must
// be dismissed (but without sending any data to the server). We implement
// this by sending the shiny-internal:brushed event to all plots, and letting
// each plot decide for itself what to do.
//
// The decision to have the event sent to each plot (as opposed to a single
// event triggered on, say, the document) was made to make cleanup easier;
// listening on an event on the document would prevent garbage collection
// of plot outputs that are removed from the document.
$el.on("shiny-internal:brushed.image_output", function (e, coords) {
// If the new brush shares our ID but not our output element ID, we
// need to clear our brush (if any).
if (coords.brushId === inputId && coords.outputId !== outputId) {
$el.data("mostRecentBrush", false);
brush.reset();
}
});
// Set cursor to one of 7 styles. We need to set the cursor on the whole
// el instead of the brush div, because the brush div has
// 'pointer-events:none' so that it won't intercept pointer events.
// If `style` is null, don't add a cursor style.
function setCursorStyle(style) {
$el.removeClass(
"crosshair grabbable grabbing ns-resize ew-resize nesw-resize nwse-resize"
);
if (style) $el.addClass(style);
}
function sendBrushInfo() {
const coords: BrushInfo = brush.boundsData();
// We're in a new or reset state
if (isNaN(coords.xmin)) {
shinySetInputValue(inputId, null);
// Must tell other brushes to clear.
imageOutputBinding
.find(document.documentElement)
.trigger("shiny-internal:brushed", {
brushId: inputId,
outputId: null,
});
return;
}
const panel = brush.getPanel();
// Add the panel (facet) variables, if present
$.extend(coords, panel.panel_vars);
// eslint-disable-next-line camelcase
coords.coords_css = brush.boundsCss();
// eslint-disable-next-line camelcase
coords.coords_img = coordmap.scaleCssToImg(coords.coords_css);
// eslint-disable-next-line camelcase
coords.img_css_ratio = coordmap.cssToImgScalingRatio();
// Add variable name mappings
coords.mapping = panel.mapping;
// Add scaling information
coords.domain = panel.domain;
coords.range = panel.range;
coords.log = panel.log;
coords.direction = opts.brushDirection;
coords.brushId = inputId;
coords.outputId = outputId;
// Send data to server
shinySetInputValue(inputId, coords);
$el.data("mostRecentBrush", true);
imageOutputBinding
.find(document.documentElement)
.trigger("shiny-internal:brushed", coords);
}
let brushInfoSender;
if (opts.brushDelayType === "throttle") {
brushInfoSender = new Throttler(null, sendBrushInfo, opts.brushDelay);
} else {
brushInfoSender = new Debouncer(null, sendBrushInfo, opts.brushDelay);
}
function mousedown(e: JQuery.MouseDownEvent) {
// This can happen when mousedown inside the graphic, then mouseup
// outside, then mousedown inside. Just ignore the second
// mousedown.
if (brush.isBrushing() || brush.isDragging() || brush.isResizing()) return;
// Listen for left mouse button only
if (e.which !== 1) return;
// In general, brush uses css pixels, and coordmap uses img pixels.
const offsetCss = coordmap.mouseOffsetCss(e);
// Ignore mousedown events outside of plotting region, expanded by
// a number of pixels specified in expandPixels.
if (opts.brushClip && !coordmap.isInPanelCss(offsetCss, expandPixels))
return;
brush.up({ x: NaN, y: NaN });
brush.down(offsetCss);
if (brush.isInResizeArea(offsetCss)) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error; TODO-barret; Remove the variable? it is not used
brush.startResizing(offsetCss);
// Attach the move and up handlers to the window so that they respond
// even when the mouse is moved outside of the image.
$(document)
.on("mousemove.image_brush", mousemoveResizing)
.on("mouseup.image_brush", mouseupResizing);
} else if (brush.isInsideBrush(offsetCss)) {
// @ts-expect-error; TODO-barret this variable is not respected
brush.startDragging(offsetCss);
setCursorStyle("grabbing");
// Attach the move and up handlers to the window so that they respond
// even when the mouse is moved outside of the image.
$(document)
.on("mousemove.image_brush", mousemoveDragging)
.on("mouseup.image_brush", mouseupDragging);
} else {
const panel = coordmap.getPanelCss(offsetCss, expandPixels);
// @ts-expect-error; TODO-barret start brushing does not take any args; Either change the function to ignore, or do not send to function;
brush.startBrushing(panel.clipImg(coordmap.scaleCssToImg(offsetCss)));
// Attach the move and up handlers to the window so that they respond
// even when the mouse is moved outside of the image.
$(document)
.on("mousemove.image_brush", mousemoveBrushing)
.on("mouseup.image_brush", mouseupBrushing);
}
}
// This sets the cursor style when it's in the el
function mousemove(e: JQuery.MouseMoveEvent) {
// In general, brush uses css pixels, and coordmap uses img pixels.
const offsetCss = coordmap.mouseOffsetCss(e);
if (!(brush.isBrushing() || brush.isDragging() || brush.isResizing())) {
// Set the cursor depending on where it is
if (brush.isInResizeArea(offsetCss)) {
const r = brush.whichResizeSides(offsetCss);
if ((r.left && r.top) || (r.right && r.bottom)) {
setCursorStyle("nwse-resize");
} else if ((r.left && r.bottom) || (r.right && r.top)) {
setCursorStyle("nesw-resize");
} else if (r.left || r.right) {
setCursorStyle("ew-resize");
} else if (r.top || r.bottom) {
setCursorStyle("ns-resize");
}
} else if (brush.isInsideBrush(offsetCss)) {
setCursorStyle("grabbable");
} else if (coordmap.isInPanelCss(offsetCss, expandPixels)) {
setCursorStyle("crosshair");
} else {
setCursorStyle(null);
}
}
}
// mousemove handlers while brushing or dragging
function mousemoveBrushing(e: JQuery.MouseMoveEvent) {
brush.brushTo(coordmap.mouseOffsetCss(e));
brushInfoSender.normalCall();
}
function mousemoveDragging(e: JQuery.MouseMoveEvent) {
brush.dragTo(coordmap.mouseOffsetCss(e));
brushInfoSender.normalCall();
}
function mousemoveResizing(e: JQuery.MouseMoveEvent) {
brush.resizeTo(coordmap.mouseOffsetCss(e));
brushInfoSender.normalCall();
}
// mouseup handlers while brushing or dragging
function mouseupBrushing(e: JQuery.MouseUpEvent) {
// Listen for left mouse button only
if (e.which !== 1) return;
$(document).off("mousemove.image_brush").off("mouseup.image_brush");
brush.up(coordmap.mouseOffsetCss(e));
brush.stopBrushing();
setCursorStyle("crosshair");
// If the brush didn't go anywhere, hide the brush, clear value,
// and return.
if (brush.down().x === brush.up().x && brush.down().y === brush.up().y) {
brush.reset();
brushInfoSender.immediateCall();
return;
}
// Send info immediately on mouseup, but only if needed. If we don't
// do the pending check, we might send the same data twice (with
// with difference nonce).
if (brushInfoSender.isPending()) brushInfoSender.immediateCall();
}
function mouseupDragging(e: JQuery.MouseUpEvent) {
// Listen for left mouse button only
if (e.which !== 1) return;
$(document).off("mousemove.image_brush").off("mouseup.image_brush");
brush.up(coordmap.mouseOffsetCss(e));
brush.stopDragging();
setCursorStyle("grabbable");
if (brushInfoSender.isPending()) brushInfoSender.immediateCall();
}
function mouseupResizing(e: JQuery.MouseUpEvent) {
// Listen for left mouse button only
if (e.which !== 1) return;
$(document).off("mousemove.image_brush").off("mouseup.image_brush");
brush.up(coordmap.mouseOffsetCss(e));
brush.stopResizing();
if (brushInfoSender.isPending()) brushInfoSender.immediateCall();
}
// Brush maintenance: When an image is re-rendered, the brush must either
// be removed (if brushResetOnNew) or imported (if !brushResetOnNew). The
// "mostRecentBrush" bit is to ensure that when multiple outputs share the
// same brush ID, inactive brushes don't send null values up to the server.
// This should be called when the img (not the el) is reset
function onResetImg() {
if (opts.brushResetOnNew) {
if ($el.data("mostRecentBrush")) {
brush.reset();
brushInfoSender.immediateCall();
}
}
}
if (!opts.brushResetOnNew) {
if ($el.data("mostRecentBrush")) {
// Importing an old brush must happen after the image data has loaded
// and the <img> DOM element has the updated size. If importOldBrush()
// is called before this happens, then the css-img coordinate mappings
// will give the wrong result, and the brush will have the wrong
// position.
//
// jcheng 09/26/2018: This used to happen in img.onLoad, but recently
// we moved to all brush initialization moving to img.onLoad so this
// logic can be executed inline.
brush.importOldBrush();
brushInfoSender.immediateCall();
}
}
function onResize() {
brush.onResize();
brushInfoSender.immediateCall();
}
return {
mousedown: mousedown,
mousemove: mousemove,
onResetImg: onResetImg,
onResize: onResize,
};
}
export { createClickHandler, createHoverHandler, createBrushHandler };
export type { BrushInfo };

View File

@@ -0,0 +1,22 @@
function disableDrag(
$el: JQuery<HTMLElement>,
$img: JQuery<HTMLElement>
): void {
// Make image non-draggable (Chrome, Safari)
$img.css("-webkit-user-drag", "none");
// Firefox, IE<=10
// First remove existing handler so we don't keep adding handlers.
$img.off("dragstart.image_output");
$img.on("dragstart.image_output", function () {
return false;
});
// Disable selection of image and text when dragging in IE<=10
$el.off("selectstart.image_output");
$el.on("selectstart.image_output", function () {
return false;
});
}
export { disableDrag };

View File

@@ -0,0 +1,22 @@
// Given two sets of x/y coordinates, return an object representing the min
// and max x and y values. (This could be generalized to any number of
import type { BoundsType } from "./createBrush";
type OffsetType = {
x: number;
y: number;
};
// points).
function findBox(offset1: OffsetType, offset2: OffsetType): BoundsType {
return {
xmin: Math.min(offset1.x, offset2.x),
xmax: Math.max(offset1.x, offset2.x),
ymin: Math.min(offset1.y, offset2.y),
ymax: Math.max(offset1.y, offset2.y),
};
}
export type { OffsetType };
export { findBox };

View File

@@ -0,0 +1,25 @@
import { createBrush } from "./createBrush";
import { createClickInfo } from "./createClickInfo";
import {
createClickHandler,
createHoverHandler,
createBrushHandler,
} from "./createHandlers";
import { disableDrag } from "./disableDrag";
import { findBox } from "./findbox";
import { initCoordmap } from "./initCoordmap";
import { initPanelScales } from "./initPanelScales";
import { shiftToRange } from "./shiftToRange";
export {
disableDrag,
initPanelScales,
initCoordmap,
findBox,
shiftToRange,
createClickInfo,
createClickHandler,
createHoverHandler,
createBrushHandler,
createBrush,
};

View File

@@ -0,0 +1,406 @@
import $ from "jquery";
import { shinySetInputValue } from "../shiny/initedMethods";
import { mapValues } from "../utils";
import type { OffsetType } from "./findbox";
import type { BoundsType } from "./createBrush";
import type { PanelType } from "./initPanelScales";
import { initPanelScales } from "./initPanelScales";
// -----------------------------------------------------------------------
// Utility functions for finding dimensions and locations of DOM elements
// -----------------------------------------------------------------------
// Returns the ratio that an element has been scaled (for example, by CSS
// transforms) in the x and y directions.
function findScalingRatio($el: JQuery<HTMLElement>) {
const boundingRect = $el[0].getBoundingClientRect();
return {
x: boundingRect.width / $el.outerWidth(),
y: boundingRect.height / $el.outerHeight(),
};
}
function findOrigin($el: JQuery<HTMLElement>): OffsetType {
const offset = $el.offset();
const scalingRatio = findScalingRatio($el);
// Find the size of the padding and border, for the top and left. This is
// before any transforms.
const paddingBorder = {
left:
parseInt($el.css("border-left-width")) +
parseInt($el.css("padding-left")),
top:
parseInt($el.css("border-top-width")) + parseInt($el.css("padding-top")),
};
// offset() returns the upper left corner of the element relative to the
// page, but it includes padding and border. Here we find the upper left
// of the element, not including padding and border.
return {
x: offset.left + scalingRatio.x * paddingBorder.left,
y: offset.top + scalingRatio.y * paddingBorder.top,
};
}
// Find the dimensions of a tag, after transforms, and without padding and
// border.
function findDims($el: JQuery<HTMLElement>) {
// If there's any padding/border, we need to find the ratio of the actual
// element content compared to the element plus padding and border.
const contentRatio = {
x: $el.width() / $el.outerWidth(),
y: $el.height() / $el.outerHeight(),
};
// Get the dimensions of the element _after_ any CSS transforms. This
// includes the padding and border.
const boundingRect = $el[0].getBoundingClientRect();
// Dimensions of the element after any CSS transforms, and without
// padding/border.
return {
x: contentRatio.x * boundingRect.width,
y: contentRatio.y * boundingRect.height,
};
}
type OffsetCssType = Record<string, number>;
type OffsetImgType = Record<string, number>;
type Coords = {
// eslint-disable-next-line camelcase
coords_css: OffsetType;
// eslint-disable-next-line camelcase
coords_img: OffsetType;
x?: number;
y?: number;
// eslint-disable-next-line camelcase
img_css_ratio?: OffsetType;
mapping?: PanelType["mapping"];
domain?: PanelType["domain"];
range?: PanelType["range"];
log?: PanelType["log"];
};
type CoordmapInitType = {
panels: Array<PanelType>;
dims: {
height: number;
width: number;
};
};
type CoordmapType = {
panels: Array<PanelType>;
dims: {
height: number;
width: number;
};
mouseOffsetCss: (evt: JQuery.MouseEventBase) => OffsetType;
scaleCssToImg: {
(offsetCss: BoundsType): BoundsType;
(offsetCss: OffsetType): OffsetType;
(offsetCss: OffsetCssType): OffsetImgType;
};
scaleImgToCss: {
(offsetImg: BoundsType): BoundsType;
(offsetImg: OffsetType): OffsetType;
(offsetImg: OffsetImgType): OffsetCssType;
};
imgToCssScalingRatio: () => OffsetType;
cssToImgScalingRatio: () => OffsetType;
getPanelCss: (offsetCss: OffsetCssType, expand?: number) => PanelType;
isInPanelCss: (offsetCss: OffsetCssType, expand?: number) => boolean;
mouseCoordinateSender: (
inputId: string,
clip?: boolean,
nullOutside?: boolean
) => (e: JQuery.MouseDownEvent) => void;
};
// This adds functions to the coordmap object to handle various
// coordinate-mapping tasks, and send information to the server. The input
// coordmap is an array of objects, each of which represents a panel. coordmap
// must be an array, even if empty, so that it can be modified in place; when
// empty, we add a dummy panel to the array. It also calls initPanelScales,
// which modifies each panel object to have scaleImgToData, scaleDataToImg,
// and clip functions.
//
// There are three coordinate spaces which we need to translate between:
//
// 1. css: The pixel coordinates in the web browser, also known as CSS pixels.
// The origin is the upper-left corner of the <img> (not including padding
// and border).
// 2. img: The pixel coordinates of the image data. A common case is on a
// HiDPI device, where the source PNG image could be 1000 pixels wide but
// be displayed in 500 CSS pixels. Another case is when the image has
// additional scaling due to CSS transforms or width.
// 3. data: The coordinates in the data space. This is a bit more complicated
// than the other two, because there can be multiple panels (as in facets).
function initCoordmap(
$el: JQuery<HTMLElement>,
coordmap_: CoordmapInitType
): CoordmapType {
const coordmap = coordmap_ as CoordmapType;
const $img = $el.find("img");
const img = $img[0];
// If we didn't get any panels, create a dummy one where the domain and range
// are simply the pixel dimensions.
// that we modify.
if (coordmap.panels.length === 0) {
const bounds = {
top: 0,
left: 0,
right: img.clientWidth - 1,
bottom: img.clientHeight - 1,
};
coordmap.panels[0] = {
domain: bounds,
range: bounds,
mapping: {},
};
}
// If no dim height and width values are found, set them to the raw image height and width
// These values should be the same...
// This is only done to initialize an image output, whose height and width are unknown until the image is retrieved
coordmap.dims.height = coordmap.dims.height || img.naturalHeight;
coordmap.dims.width = coordmap.dims.width || img.naturalWidth;
// Add scaling functions to each panel
initPanelScales(coordmap.panels);
// This returns the offset of the mouse in CSS pixels relative to the img,
// but not including the padding or border, if present.
coordmap.mouseOffsetCss = function (mouseEvent) {
const imgOrigin = findOrigin($img);
// The offset of the mouse from the upper-left corner of the img, in
// pixels.
return {
x: mouseEvent.pageX - imgOrigin.x,
y: mouseEvent.pageY - imgOrigin.y,
};
};
// Given an offset in an img in CSS pixels, return the corresponding offset
// in source image pixels. The offsetCss can have properties like "x",
// "xmin", "y", and "ymax" -- anything that starts with "x" and "y". If the
// img content is 1000 pixels wide, but is scaled to 400 pixels on screen,
// and the input is x:400, then this will return x:1000.
function scaleCssToImg(offsetCss: BoundsType): BoundsType;
function scaleCssToImg(offsetCss: OffsetType): OffsetType;
function scaleCssToImg(offsetCss: OffsetCssType): OffsetImgType;
function scaleCssToImg(offsetCss) {
const pixelScaling = coordmap.imgToCssScalingRatio();
const result = mapValues(offsetCss, (value, key) => {
const prefix = key.substring(0, 1);
if (prefix === "x") {
return offsetCss[key] / pixelScaling.x;
} else if (prefix === "y") {
return offsetCss[key] / pixelScaling.y;
}
return null;
});
return result;
}
coordmap.scaleCssToImg = scaleCssToImg;
// Given an offset in an img, in source image pixels, return the
// corresponding offset in CSS pixels. If the img content is 1000 pixels
// wide, but is scaled to 400 pixels on screen, and the input is x:1000,
// then this will return x:400.
function scaleImgToCss(offsetImg: BoundsType): BoundsType;
function scaleImgToCss(offsetImg: OffsetType): OffsetType;
function scaleImgToCss(offsetImg: OffsetImgType): OffsetCssType;
function scaleImgToCss(
offsetImg: Record<string, number>
): Record<string, number> {
const pixelScaling = coordmap.imgToCssScalingRatio();
const result = mapValues(offsetImg, (value, key) => {
const prefix = key.substring(0, 1);
if (prefix === "x") {
return offsetImg[key] * pixelScaling.x;
} else if (prefix === "y") {
return offsetImg[key] * pixelScaling.y;
}
return null;
});
return result;
}
coordmap.scaleImgToCss = scaleImgToCss;
// Returns the x and y ratio the image content is scaled to on screen. If
// the image data is 1000 pixels wide and is scaled to 300 pixels on screen,
// then this returns 0.3. (Note the 300 pixels refers to CSS pixels.)
coordmap.imgToCssScalingRatio = function () {
const imgDims = findDims($img);
return {
x: imgDims.x / coordmap.dims.width,
y: imgDims.y / coordmap.dims.height,
};
};
coordmap.cssToImgScalingRatio = function () {
const res = coordmap.imgToCssScalingRatio();
return {
x: 1 / res.x,
y: 1 / res.y,
};
};
// Given an offset in css pixels, return an object representing which panel
// it's in. The `expand` argument tells it to expand the panel area by that
// many pixels. It's possible for an offset to be within more than one
// panel, because of the `expand` value. If that's the case, find the
// nearest panel.
coordmap.getPanelCss = function (offsetCss, expand = 0) {
const offsetImg = coordmap.scaleCssToImg(offsetCss);
const x = offsetImg.x;
const y = offsetImg.y;
// Convert expand from css pixels to img pixels
const cssToImgRatio = coordmap.cssToImgScalingRatio();
const expandImg = {
x: expand * cssToImgRatio.x,
y: expand * cssToImgRatio.y,
};
const matches = []; // Panels that match
const dists = []; // Distance of offset to each matching panel
let b;
let i;
for (i = 0; i < coordmap.panels.length; i++) {
b = coordmap.panels[i].range;
if (
x <= b.right + expandImg.x &&
x >= b.left - expandImg.x &&
y <= b.bottom + expandImg.y &&
y >= b.top - expandImg.y
) {
matches.push(coordmap.panels[i]);
// Find distance from edges for x and y
let xdist = 0;
let ydist = 0;
if (x > b.right && x <= b.right + expandImg.x) {
xdist = x - b.right;
} else if (x < b.left && x >= b.left - expandImg.x) {
xdist = x - b.left;
}
if (y > b.bottom && y <= b.bottom + expandImg.y) {
ydist = y - b.bottom;
} else if (y < b.top && y >= b.top - expandImg.y) {
ydist = y - b.top;
}
// Cartesian distance
dists.push(Math.sqrt(Math.pow(xdist, 2) + Math.pow(ydist, 2)));
}
}
if (matches.length) {
// Find shortest distance
const minDist = Math.min.apply(null, dists);
for (i = 0; i < matches.length; i++) {
if (dists[i] === minDist) {
return matches[i];
}
}
}
return null;
};
// Is an offset (in css pixels) in a panel? If supplied, `expand` tells us
// to expand the panels by that many pixels in all directions.
coordmap.isInPanelCss = function (offsetCss, expand = 0) {
if (coordmap.getPanelCss(offsetCss, expand)) return true;
return false;
};
// Returns a function that sends mouse coordinates, scaled to data space.
// If that function is passed a null event, it will send null.
coordmap.mouseCoordinateSender = function (
inputId,
clip = true,
nullOutside = false
) {
return function (e) {
if (e === null) {
shinySetInputValue(inputId, null);
return;
}
const coordsCss = coordmap.mouseOffsetCss(e);
// If outside of plotting region
if (!coordmap.isInPanelCss(coordsCss)) {
if (nullOutside) {
shinySetInputValue(inputId, null);
return;
}
if (clip) return;
const coords: Coords = {
// eslint-disable-next-line camelcase
coords_css: coordsCss,
// eslint-disable-next-line camelcase
coords_img: coordmap.scaleCssToImg(coordsCss),
};
shinySetInputValue(inputId, coords, { priority: "event" });
return;
}
const panel = coordmap.getPanelCss(coordsCss);
const coordsImg = coordmap.scaleCssToImg(coordsCss);
const coordsData = panel.scaleImgToData(coordsImg);
const coords: Coords = {
x: coordsData.x,
y: coordsData.y,
// eslint-disable-next-line camelcase
coords_css: coordsCss,
// eslint-disable-next-line camelcase
coords_img: coordsImg,
// eslint-disable-next-line camelcase
img_css_ratio: coordmap.cssToImgScalingRatio(),
};
// Add the panel (facet) variables, if present
$.extend(coords, panel.panel_vars);
// Add variable name mappings
coords.mapping = panel.mapping;
// Add scaling information
coords.domain = panel.domain;
coords.range = panel.range;
coords.log = panel.log;
shinySetInputValue(inputId, coords, { priority: "event" });
};
};
return coordmap;
}
export type { CoordmapType, CoordmapInitType };
export { initCoordmap, findOrigin };

View File

@@ -0,0 +1,162 @@
// Map a value x from a domain to a range. If clip is true, clip it to the
import { OffsetType } from "./findbox";
import { mapValues } from "../utils";
// range.
function mapLinear(
x: number,
domainMin: number,
domainMax: number,
rangeMin: number,
rangeMax: number,
clip = true
) {
// By default, clip to range
clip = clip || true;
const factor = (rangeMax - rangeMin) / (domainMax - domainMin);
const val = x - domainMin;
let newval = val * factor + rangeMin;
if (clip) {
const max = Math.max(rangeMax, rangeMin);
const min = Math.min(rangeMax, rangeMin);
if (newval > max) newval = max;
else if (newval < min) newval = min;
}
return newval;
}
// Create scale and inverse-scale functions for a single direction (x or y).
function scaler1D(
domainMin: number,
domainMax: number,
rangeMin: number,
rangeMax: number,
logbase: number
) {
return {
scale: function (val: number, clip: boolean) {
if (logbase) val = Math.log(val) / Math.log(logbase);
return mapLinear(val, domainMin, domainMax, rangeMin, rangeMax, clip);
},
scaleInv: function (val: number, clip?: boolean) {
let res = mapLinear(val, rangeMin, rangeMax, domainMin, domainMax, clip);
if (logbase) res = Math.pow(logbase, res);
return res;
},
};
}
type PanelType = {
domain: {
top: number;
bottom: number;
left: number;
right: number;
};
range: {
top: number;
bottom: number;
left: number;
right: number;
};
log?: {
x?: number;
y?: number;
};
mapping: Record<string, string>;
// eslint-disable-next-line camelcase
panel_vars?: Record<string, number | string>;
scaleDataToImg?: (
val: Record<string, number>,
clip?: boolean
) => Record<string, number>;
scaleImgToData?: {
(val: OffsetType, clip?: boolean): OffsetType;
(val: Record<string, number>, clip?: boolean): Record<string, number>;
};
clipImg?: (offsetImg: { x: number; y: number }) => { x: number; y: number };
};
// Modify panel, adding scale and inverse-scale functions that take objects
// like {x:1, y:3}, and also add clip function.
function addScaleFuns(panel: PanelType) {
const d = panel.domain;
const r = panel.range;
const xlog = panel.log && panel.log.x ? panel.log.x : null;
const ylog = panel.log && panel.log.y ? panel.log.y : null;
const xscaler = scaler1D(d.left, d.right, r.left, r.right, xlog);
const yscaler = scaler1D(d.bottom, d.top, r.bottom, r.top, ylog);
// Given an object of form {x:1, y:2}, or {x:1, xmin:2:, ymax: 3}, convert
// from data coordinates to img. Whether a value is converted as x or y
// depends on the first character of the key.
panel.scaleDataToImg = function (val, clip) {
return mapValues(val, (value, key) => {
const prefix = key.substring(0, 1);
if (prefix === "x") {
return xscaler.scale(value, clip);
} else if (prefix === "y") {
return yscaler.scale(value, clip);
}
return null;
});
};
function scaleImgToData(val: OffsetType, clip?: boolean);
function scaleImgToData(val: Record<string, number>, clip?: boolean) {
return mapValues(val, (value, key) => {
const prefix = key.substring(0, 1);
if (prefix === "x") {
return xscaler.scaleInv(value, clip);
} else if (prefix === "y") {
return yscaler.scaleInv(value, clip);
}
return null;
});
}
panel.scaleImgToData = scaleImgToData;
// Given a scaled offset (in img pixels), clip it to the nearest panel region.
panel.clipImg = function (offsetImg) {
const newOffset = {
x: offsetImg.x,
y: offsetImg.y,
};
const bounds = panel.range;
if (offsetImg.x > bounds.right) newOffset.x = bounds.right;
else if (offsetImg.x < bounds.left) newOffset.x = bounds.left;
if (offsetImg.y > bounds.bottom) newOffset.y = bounds.bottom;
else if (offsetImg.y < bounds.top) newOffset.y = bounds.top;
return newOffset;
};
}
// Modifies the panel objects in a coordmap, adding scaleImgToData(),
// scaleDataToImg(), and clipImg() functions to each one. The panel objects
// use img and data coordinates only; they do not use css coordinates. The
// domain is in data coordinates; the range is in img coordinates.
function initPanelScales(panels: Array<PanelType>): void {
// Add the functions to each panel object.
for (let i = 0; i < panels.length; i++) {
const panel = panels[i];
addScaleFuns(panel);
}
}
export type { PanelType };
export { initPanelScales };

View File

@@ -0,0 +1,14 @@
import { imageOutputBinding } from "../bindings/output/image";
import { shinySetInputValue } from "../shiny/initedMethods";
function resetBrush(brushId: string): void {
shinySetInputValue(brushId, null);
imageOutputBinding
.find(document.documentElement)
.trigger("shiny-internal:brushed", {
brushId: brushId,
outputId: null,
});
}
export { resetBrush };

View File

@@ -0,0 +1,30 @@
// Shift an array of values so that they are within a min and max. The vals
// will be shifted so that they maintain the same spacing internally. If the
// range in vals is larger than the range of min and max, the result might not
// make sense.
function shiftToRange(
vals: number | Array<number>,
min: number,
max: number
): Array<number> {
if (!(vals instanceof Array)) vals = [vals];
const maxval = Math.max.apply(null, vals);
const minval = Math.min.apply(null, vals);
let shiftAmount = 0;
if (maxval > max) {
shiftAmount = max - maxval;
} else if (minval < min) {
shiftAmount = min - minval;
}
const newvals = [];
for (let i = 0; i < vals.length; i++) {
newvals[i] = vals[i] + shiftAmount;
}
return newvals;
}
export { shiftToRange };

View File

@@ -1,13 +1,3 @@
import { init } from "./initialize";
import { Shiny } from "./shiny";
import { main } from "./main";
init();
main();
// Set Shiny globally
window["Shiny"] = Shiny;
window.console.log("Shiny version: ", Shiny.version);

View File

@@ -9,6 +9,8 @@ import { windowBlobBuilder } from "../window/blobBuilder";
import { setUserAgent } from "../utils/userAgent";
import { windowUserAgent } from "../window/userAgent";
import { initReactlog } from "../shiny/reactlog";
function init(): void {
setShiny(windowShiny());
setUserAgent(windowUserAgent()); // before determineBrowserInfo()
@@ -19,6 +21,8 @@ function init(): void {
disableFormSubmission();
setBlobBuilder(windowBlobBuilder());
initReactlog();
}
export { init };

View File

@@ -0,0 +1,21 @@
type priorityType = "immediate" | "deferred" | "event";
// Schedules data to be sent to shinyapp at the next setTimeout(0).
// Batches multiple input calls into one websocket message.
class InputPolicy {
target: InputPolicy;
setInput(
name: string,
value: unknown,
opts: { priority: priorityType }
): void {
throw "not implemented";
name;
value;
opts;
}
}
export { InputPolicy };
export type { priorityType };

View File

@@ -0,0 +1,20 @@
import { InputBatchSender } from "./inputBatchSender";
import { InputNoResendDecorator } from "./inputNoResendDecorator";
import { InputEventDecorator } from "./inputEventDecorator";
import { InputRateDecorator } from "./inputRateDecorator";
import { InputDeferDecorator } from "./inputDeferDecorator";
import { InputValidateDecorator } from "./inputValidateDecorator";
import { priorityType, InputPolicy } from "./InputPolicy";
export {
InputBatchSender,
InputEventDecorator,
InputNoResendDecorator,
InputRateDecorator,
InputDeferDecorator,
InputValidateDecorator,
InputPolicy,
};
export type { priorityType };

View File

@@ -0,0 +1,56 @@
import $ from "jquery";
import { priorityType, InputPolicy } from "./InputPolicy";
import { ShinyApp } from "../shiny/shinyapp";
// Schedules data to be sent to shinyapp at the next setTimeout(0).
// Batches multiple input calls into one websocket message.
class InputBatchSender extends InputPolicy {
shinyapp: ShinyApp;
timerId: NodeJS.Timeout = null;
pendingData: Record<string, unknown> = {};
reentrant = false;
lastChanceCallback: Array<() => void> = [];
constructor(shinyapp: ShinyApp) {
super();
this.shinyapp = shinyapp;
}
setInput(
nameType: string,
value: unknown,
opts: { priority: priorityType }
): void {
this.pendingData[nameType] = value;
if (!this.reentrant) {
if (opts.priority === "event") {
this.$sendNow();
} else if (!this.timerId) {
this.timerId = setTimeout(this.$sendNow.bind(this), 0);
}
}
}
private $sendNow(): void {
if (this.reentrant) {
console.trace("Unexpected reentrancy in InputBatchSender!");
}
this.reentrant = true;
try {
this.timerId = null;
$.each(this.lastChanceCallback, (i, callback) => {
callback();
});
const currentData = this.pendingData;
this.pendingData = {};
this.shinyapp.sendInput(currentData);
} finally {
this.reentrant = false;
}
}
}
export { InputBatchSender };

View File

@@ -0,0 +1,33 @@
import { priorityType, InputPolicy } from "./InputPolicy";
import { hasOwnProperty } from "../utils";
class InputDeferDecorator extends InputPolicy {
pendingInput: Record<
string,
{ value: unknown; opts: { priority: priorityType } }
> = {};
constructor(target: InputPolicy) {
super();
this.target = target;
}
setInput(
nameType: string,
value: unknown,
opts: { priority: priorityType }
): void {
if (/^\./.test(nameType)) this.target.setInput(nameType, value, opts);
else this.pendingInput[nameType] = { value, opts };
}
submit(): void {
for (const nameType in this.pendingInput) {
if (hasOwnProperty(this.pendingInput, nameType)) {
const { value, opts } = this.pendingInput[nameType];
this.target.setInput(nameType, value, opts);
}
}
}
}
export { InputDeferDecorator };

View File

@@ -0,0 +1,48 @@
import $ from "jquery";
import type { priorityType } from "./InputPolicy";
import { InputPolicy } from "./InputPolicy";
import type { InputBinding } from "../bindings";
import type { ShinyEventInputChanged } from "../events/shinyEvents";
import { splitInputNameType } from "./splitInputNameType";
class InputEventDecorator extends InputPolicy {
constructor(target: InputPolicy) {
super();
this.target = target;
}
setInput(
nameType: string,
value: unknown,
opts: {
el: HTMLElement;
priority: priorityType;
binding: InputBinding;
}
): void {
const evt = jQuery.Event("shiny:inputchanged") as ShinyEventInputChanged;
const input = splitInputNameType(nameType);
evt.name = input.name;
evt.inputType = input.inputType;
evt.value = value;
evt.binding = opts.binding;
evt.el = opts.el;
evt.priority = opts.priority;
$(opts.el).trigger(evt);
if (!evt.isDefaultPrevented()) {
let name = evt.name;
if (evt.inputType !== "") name += ":" + evt.inputType;
// Most opts aren't passed along to lower levels in the input decorator
// stack.
this.target.setInput(name, evt.value, { priority: opts.priority });
}
}
}
export { InputEventDecorator };

View File

@@ -0,0 +1,62 @@
import { priorityType, InputPolicy } from "./InputPolicy";
import { hasOwnProperty } from "../utils";
import { splitInputNameType } from "./splitInputNameType";
type lastSentValuesType = Record<string, Record<string, string>>;
class InputNoResendDecorator extends InputPolicy {
lastSentValues: lastSentValuesType;
constructor(target: InputPolicy, initialValues: lastSentValuesType = {}) {
super();
this.target = target;
this.reset(initialValues);
}
setInput(
nameType: string,
value: unknown,
opts: { priority: priorityType }
): void {
const { name: inputName, inputType: inputType } =
splitInputNameType(nameType);
const jsonValue = JSON.stringify(value);
if (
opts.priority !== "event" &&
this.lastSentValues[inputName] &&
this.lastSentValues[inputName].jsonValue === jsonValue &&
this.lastSentValues[inputName].inputType === inputType
) {
return;
}
this.lastSentValues[inputName] = { jsonValue, inputType };
this.target.setInput(nameType, value, opts);
}
reset(values = {}): void {
// Given an object with flat name-value format:
// { x: "abc", "y.shiny.number": 123 }
// Create an object in cache format and save it:
// { x: { jsonValue: '"abc"', inputType: "" },
// y: { jsonValue: "123", inputType: "shiny.number" } }
const cacheValues = {};
for (const inputName in values) {
if (hasOwnProperty(values, inputName)) {
const { name, inputType } = splitInputNameType(inputName);
cacheValues[name] = {
jsonValue: JSON.stringify(values[inputName]),
inputType: inputType,
};
}
}
this.lastSentValues = cacheValues;
}
forget(name: string): void {
delete this.lastSentValues[name];
}
}
export { InputNoResendDecorator };

View File

@@ -0,0 +1,70 @@
import { priorityType, InputPolicy } from "./InputPolicy";
import { Debouncer, Invoker, Throttler } from "../time";
import { splitInputNameType } from "./splitInputNameType";
type RatePolicyModes = "direct" | "debounce" | "throttle";
class InputRateDecorator extends InputPolicy {
inputRatePolicies = {};
constructor(target: InputPolicy) {
super();
this.target = target;
}
// Note that the first argument of setInput() and setRatePolicy()
// are passed both the input name (i.e., inputId) and type.
// https://github.com/rstudio/shiny/blob/67d3a/srcjs/init_shiny.js#L111-L126
// However, $ensureInit() and $doSetInput() are meant to be passed just
// the input name (i.e., inputId), which is why we distinguish between
// nameType and name.
setInput(
nameType: string,
value: unknown,
opts: { priority: priorityType }
): void {
const { name: inputName } = splitInputNameType(nameType);
this.$ensureInit(inputName);
if (opts.priority !== "deferred")
this.inputRatePolicies[inputName].immediateCall(nameType, value, opts);
else this.inputRatePolicies[inputName].normalCall(nameType, value, opts);
}
setRatePolicy(
nameType: string,
mode: RatePolicyModes,
millis?: number
): void {
const { name: inputName } = splitInputNameType(nameType);
if (mode === "direct") {
this.inputRatePolicies[inputName] = new Invoker(this, this.$doSetInput);
} else if (mode === "debounce") {
this.inputRatePolicies[inputName] = new Debouncer(
this,
this.$doSetInput,
millis
);
} else if (mode === "throttle") {
this.inputRatePolicies[inputName] = new Throttler(
this,
this.$doSetInput,
millis
);
}
}
private $ensureInit(name: string): void {
if (!(name in this.inputRatePolicies)) this.setRatePolicy(name, "direct");
}
private $doSetInput(
nameType: string,
value: unknown,
opts: { priority: priorityType }
): void {
this.target.setInput(nameType, value, opts);
}
}
export { InputRateDecorator };
export type { RatePolicyModes };

Some files were not shown because too many files have changed in this diff Show More