mirror of
https://github.com/rstudio/shiny.git
synced 2026-01-11 07:58:11 -05:00
Compare commits
15 Commits
updateSele
...
bootstrapL
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
923c1b2450 | ||
|
|
914dc71d67 | ||
|
|
a13ca9fd3a | ||
|
|
b8b34370da | ||
|
|
bcb7cde44b | ||
|
|
052c9458b7 | ||
|
|
3fe8c27d21 | ||
|
|
1dd256b210 | ||
|
|
dc9c6ae769 | ||
|
|
2cdafed2e0 | ||
|
|
b4caa9137d | ||
|
|
dcca77c936 | ||
|
|
871b1baacc | ||
|
|
4deb699066 | ||
|
|
ccc8e053c6 |
14
.github/workflows/rituals.yaml
vendored
14
.github/workflows/rituals.yaml
vendored
@@ -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
|
||||
|
||||
10
DESCRIPTION
10
DESCRIPTION
@@ -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
16
NEWS.md
@@ -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)
|
||||
|
||||
484
R/bootstrap.R
484
R/bootstrap.R
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
36
R/modal.R
36
R/modal.R
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
),
|
||||
|
||||
2
R/version_bs_date_picker.R
Normal file
2
R/version_bs_date_picker.R
Normal file
@@ -0,0 +1,2 @@
|
||||
# Generated by tools/updateBootstrapDatepicker.R; do not edit by hand
|
||||
version_bs_date_picker <- "1.9.0"
|
||||
2
R/version_ion_range_slider.R
Normal file
2
R/version_ion_range_slider.R
Normal 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
2
R/version_selectize.R
Normal 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
2
R/version_strftime.R
Normal 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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
inst/www/shared/selectize/scss/selectize.bootstrap5.scss
Normal file
3
inst/www/shared/selectize/scss/selectize.bootstrap5.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
$input-line-height-sm: $form-select-line-height !default;
|
||||
@import 'selectize.bootstrap4';
|
||||
.selectize-control{padding:0;}
|
||||
20091
inst/www/shared/shiny.js
20091
inst/www/shared/shiny.js
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
2
inst/www/shared/shiny.min.js
vendored
2
inst/www/shared/shiny.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@
|
||||
insertTab(
|
||||
inputId,
|
||||
tab,
|
||||
target,
|
||||
target = NULL,
|
||||
position = c("before", "after"),
|
||||
select = FALSE,
|
||||
session = getDefaultReactiveDomain()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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">}.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}.}
|
||||
|
||||
|
||||
@@ -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
1
srcts/.gitignore
vendored
@@ -7,3 +7,4 @@ node_modules/
|
||||
!.yarn/versions
|
||||
.pnp.*
|
||||
coverage/
|
||||
madge.svg
|
||||
|
||||
7
srcts/.madgerc
Normal file
7
srcts/.madgerc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"detectiveOptions": {
|
||||
"ts": {
|
||||
"skipTypeImports": true
|
||||
}
|
||||
}
|
||||
}
|
||||
77
srcts/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
77
srcts/.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
20
srcts/.yarn/sdks/eslint/bin/eslint.js
vendored
20
srcts/.yarn/sdks/eslint/bin/eslint.js
vendored
@@ -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`);
|
||||
20
srcts/.yarn/sdks/eslint/lib/api.js
vendored
20
srcts/.yarn/sdks/eslint/lib/api.js
vendored
@@ -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`);
|
||||
6
srcts/.yarn/sdks/eslint/package.json
vendored
6
srcts/.yarn/sdks/eslint/package.json
vendored
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "eslint",
|
||||
"version": "7.19.0-pnpify",
|
||||
"main": "./lib/api.js",
|
||||
"type": "commonjs"
|
||||
}
|
||||
5
srcts/.yarn/sdks/integrations.yml
vendored
5
srcts/.yarn/sdks/integrations.yml
vendored
@@ -1,5 +0,0 @@
|
||||
# This file is automatically generated by PnPify.
|
||||
# Manual changes will be lost!
|
||||
|
||||
integrations:
|
||||
- vscode
|
||||
30
srcts/.yarn/sdks/prettier/index.js
vendored
30
srcts/.yarn/sdks/prettier/index.js
vendored
@@ -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`);
|
||||
6
srcts/.yarn/sdks/prettier/package.json
vendored
6
srcts/.yarn/sdks/prettier/package.json
vendored
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "prettier",
|
||||
"version": "2.2.1-pnpify",
|
||||
"main": "./index.js",
|
||||
"type": "commonjs"
|
||||
}
|
||||
20
srcts/.yarn/sdks/typescript/bin/tsc
vendored
20
srcts/.yarn/sdks/typescript/bin/tsc
vendored
@@ -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`);
|
||||
20
srcts/.yarn/sdks/typescript/bin/tsserver
vendored
20
srcts/.yarn/sdks/typescript/bin/tsserver
vendored
@@ -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`);
|
||||
20
srcts/.yarn/sdks/typescript/lib/tsc.js
vendored
20
srcts/.yarn/sdks/typescript/lib/tsc.js
vendored
@@ -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`);
|
||||
111
srcts/.yarn/sdks/typescript/lib/tsserver.js
vendored
111
srcts/.yarn/sdks/typescript/lib/tsserver.js
vendored
@@ -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`));
|
||||
20
srcts/.yarn/sdks/typescript/lib/typescript.js
vendored
20
srcts/.yarn/sdks/typescript/lib/typescript.js
vendored
@@ -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`);
|
||||
6
srcts/.yarn/sdks/typescript/package.json
vendored
6
srcts/.yarn/sdks/typescript/package.json
vendored
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "typescript",
|
||||
"version": "4.1.5-pnpify",
|
||||
"main": "./lib/typescript.js",
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"useBuiltIns": "usage",
|
||||
"corejs": "3.9"
|
||||
"corejs": "3.12"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
4
srcts/src/bindings/index.ts
Normal file
4
srcts/src/bindings/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { InputBinding } from "./input";
|
||||
import { OutputBinding } from "./output";
|
||||
|
||||
export { InputBinding, OutputBinding };
|
||||
105
srcts/src/bindings/input/InputBinding.ts
Normal file
105
srcts/src/bindings/input/InputBinding.ts
Normal 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 };
|
||||
81
srcts/src/bindings/input/actionbutton.ts
Normal file
81
srcts/src/bindings/input/actionbutton.ts
Normal 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 };
|
||||
50
srcts/src/bindings/input/checkbox.ts
Normal file
50
srcts/src/bindings/input/checkbox.ts
Normal 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 };
|
||||
143
srcts/src/bindings/input/checkboxgroup.ts
Normal file
143
srcts/src/bindings/input/checkboxgroup.ts
Normal 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 };
|
||||
317
srcts/src/bindings/input/date.ts
Normal file
317
srcts/src/bindings/input/date.ts
Normal 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 };
|
||||
185
srcts/src/bindings/input/daterange.ts
Normal file
185
srcts/src/bindings/input/daterange.ts
Normal 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 };
|
||||
291
srcts/src/bindings/input/fileinput.ts
Normal file
291
srcts/src/bindings/input/fileinput.ts
Normal 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 };
|
||||
57
srcts/src/bindings/input/index.ts
Normal file
57
srcts/src/bindings/input/index.ts
Normal 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 };
|
||||
80
srcts/src/bindings/input/number.ts
Normal file
80
srcts/src/bindings/input/number.ts
Normal 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 };
|
||||
16
srcts/src/bindings/input/password.ts
Normal file
16
srcts/src/bindings/input/password.ts
Normal 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 };
|
||||
128
srcts/src/bindings/input/radio.ts
Normal file
128
srcts/src/bindings/input/radio.ts
Normal 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 };
|
||||
279
srcts/src/bindings/input/selectInput.ts
Normal file
279
srcts/src/bindings/input/selectInput.ts
Normal 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 };
|
||||
390
srcts/src/bindings/input/slider.ts
Normal file
390
srcts/src/bindings/input/slider.ts
Normal 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 };
|
||||
78
srcts/src/bindings/input/tabinput.ts
Normal file
78
srcts/src/bindings/input/tabinput.ts
Normal 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 };
|
||||
124
srcts/src/bindings/input/text.ts
Normal file
124
srcts/src/bindings/input/text.ts
Normal 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 };
|
||||
11
srcts/src/bindings/input/textarea.ts
Normal file
11
srcts/src/bindings/input/textarea.ts
Normal 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 };
|
||||
64
srcts/src/bindings/output/OutputBinding.ts
Normal file
64
srcts/src/bindings/output/OutputBinding.ts
Normal 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 };
|
||||
137
srcts/src/bindings/output/datatable.ts
Normal file
137
srcts/src/bindings/output/datatable.ts
Normal 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 };
|
||||
35
srcts/src/bindings/output/downloadlink.ts
Normal file
35
srcts/src/bindings/output/downloadlink.ts
Normal 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 };
|
||||
24
srcts/src/bindings/output/html.ts
Normal file
24
srcts/src/bindings/output/html.ts
Normal 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 };
|
||||
296
srcts/src/bindings/output/image.ts
Normal file
296
srcts/src/bindings/output/image.ts
Normal 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 };
|
||||
31
srcts/src/bindings/output/index.ts
Normal file
31
srcts/src/bindings/output/index.ts
Normal 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 };
|
||||
13
srcts/src/bindings/output/text.ts
Normal file
13
srcts/src/bindings/output/text.ts
Normal 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 };
|
||||
47
srcts/src/bindings/output_adapter.ts
Normal file
47
srcts/src/bindings/output_adapter.ts
Normal 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 };
|
||||
51
srcts/src/bindings/registry.ts
Normal file
51
srcts/src/bindings/registry.ts
Normal 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 };
|
||||
40
srcts/src/events/shinyEvents.ts
Normal file
40
srcts/src/events/shinyEvents.ts
Normal 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,
|
||||
};
|
||||
26
srcts/src/events/shiny_inputchanged.ts
Normal file
26
srcts/src/events/shiny_inputchanged.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
|
||||
641
srcts/src/imageutils/createBrush.ts
Normal file
641
srcts/src/imageutils/createBrush.ts
Normal 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 };
|
||||
106
srcts/src/imageutils/createClickInfo.ts
Normal file
106
srcts/src/imageutils/createClickInfo.ts
Normal 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 };
|
||||
418
srcts/src/imageutils/createHandlers.ts
Normal file
418
srcts/src/imageutils/createHandlers.ts
Normal 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 };
|
||||
22
srcts/src/imageutils/disableDrag.ts
Normal file
22
srcts/src/imageutils/disableDrag.ts
Normal 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 };
|
||||
22
srcts/src/imageutils/findbox.ts
Normal file
22
srcts/src/imageutils/findbox.ts
Normal 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 };
|
||||
25
srcts/src/imageutils/index.ts
Normal file
25
srcts/src/imageutils/index.ts
Normal 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,
|
||||
};
|
||||
406
srcts/src/imageutils/initCoordmap.ts
Normal file
406
srcts/src/imageutils/initCoordmap.ts
Normal 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 };
|
||||
162
srcts/src/imageutils/initPanelScales.ts
Normal file
162
srcts/src/imageutils/initPanelScales.ts
Normal 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 };
|
||||
14
srcts/src/imageutils/resetBrush.ts
Normal file
14
srcts/src/imageutils/resetBrush.ts
Normal 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 };
|
||||
30
srcts/src/imageutils/shiftToRange.ts
Normal file
30
srcts/src/imageutils/shiftToRange.ts
Normal 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 };
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
21
srcts/src/inputPolicies/InputPolicy.ts
Normal file
21
srcts/src/inputPolicies/InputPolicy.ts
Normal 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 };
|
||||
20
srcts/src/inputPolicies/index.ts
Normal file
20
srcts/src/inputPolicies/index.ts
Normal 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 };
|
||||
56
srcts/src/inputPolicies/inputBatchSender.ts
Normal file
56
srcts/src/inputPolicies/inputBatchSender.ts
Normal 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 };
|
||||
33
srcts/src/inputPolicies/inputDeferDecorator.ts
Normal file
33
srcts/src/inputPolicies/inputDeferDecorator.ts
Normal 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 };
|
||||
48
srcts/src/inputPolicies/inputEventDecorator.ts
Normal file
48
srcts/src/inputPolicies/inputEventDecorator.ts
Normal 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 };
|
||||
62
srcts/src/inputPolicies/inputNoResendDecorator.ts
Normal file
62
srcts/src/inputPolicies/inputNoResendDecorator.ts
Normal 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 };
|
||||
70
srcts/src/inputPolicies/inputRateDecorator.ts
Normal file
70
srcts/src/inputPolicies/inputRateDecorator.ts
Normal 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
Reference in New Issue
Block a user