diff --git a/.gitignore b/.gitignore index 94ba6ed..3e85914 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,6 @@ bookclub-shinyui.knit.md bookclub-shinyui_files libs _book/*.html -app.R -app2.R renv/ renv.lock diff --git a/15_optimize-your-apps-with-custom-handlers.Rmd b/15_optimize-your-apps-with-custom-handlers.Rmd index 02be148..c3c185a 100644 --- a/15_optimize-your-apps-with-custom-handlers.Rmd +++ b/15_optimize-your-apps-with-custom-handlers.Rmd @@ -1,75 +1,499 @@ # Optimize your apps with custom handlers +```{r} +#| echo: false +#| output: false + +library(htmltools) + +create_note <- function(title = "Note", + text = ""){ + + tags$div(class = "callout callout-style-default callout-note callout-titled", + tags$div(class = "callout-header align-content-center", + tags$div(class = "callout-icon-container", + tags$i(class = "callout-icon")), + tags$div(class = "callout-title-container", + tags$p(id = "text-margin", + title))), + tags$div(class = "callout-body-container callout-body", + tags$p(HTML(text)))) + +} +``` + + **Learning objectives:** -- Leverage internal Shiny JS tools to build highly interactive and optimized interfaces +- Leverage internal Shiny JS tools to build **highly interactive** and **optimized** interfaces + +## Introduction {-} -## Introduction +Many functions can update the UI from the server -- Many functions can update the UI from the server - **update** functions - `updateTextInput()`, `updateTabSetPanel()` - **toggle** functions - `hideTab()`, `showTab()` -- `renderUI()`, `insertUI()`, `removeUI()` +- **modify** user interface elements + - `renderUI()`, `insertUI()`, `removeUI()` + +
+ +```{r} +#| echo: false + +create_note(text = "The aren't many of this functions, which often obliges to use packages like shinyjs or write custom JavaScript code.") +``` -## The renderUI case +## renderUI and uiOutput {-} - `renderUI()` and `uiOutput` most famous way to **render** any HTML block from the **server** - `update____` and `toggle` tools are component-specific, only target the element to modify -- `renderUI()` re-renders the block each time, implies poor performance in complex apps +- `renderUI()` re-renders **the whole block** each time an associated _reactive dependency_ is invalidated. + +- Results in **poor performances** in complex apps. + +## renderUI and uiOutput Example Code {-} + +```{r} +#| echo: false + +create_note(text = "We used do.call to execute dropdownMenu in order to update the number of messages in the menu bar.") +``` + +```r +library(shiny) +library(bs4Dash) + +new_message <- data.frame( + message = "New message", + from = "Paul", + time = "yesterday", + color = "success" +) + +shinyApp( + ui = dashboardPage( + dark = FALSE, + header = dashboardHeader( + rightUi = uiOutput("messages", container = tags$li) + ), + sidebar = dashboardSidebar(), + controlbar = dashboardControlbar(), + footer = dashboardFooter(), + title = "test", + body = dashboardBody(actionButton("add", "Add message")) + ), + server = function(input, output) { + + messages <- reactiveValues( + items = data.frame( + message = rep("A message", 10), + from = LETTERS[1:10], + time = rep("yesterday", 10), + color = rep("success", 10) + ) + ) + + observeEvent(input$add, { + messages$items <- rbind(messages$items, new_message) + }) + + output$messages <- renderUI({ + lapply(seq_len(nrow(messages$items)), function(i) { + items_i <- messages$items[i, ] + bs4Dash::messageItem( + message = items_i$message, + from = items_i$from, + time = items_i$time, + color = items_i$color + ) + }) |> + c(badgeStatus = "danger", + type = "messages") |> + do.call(what = "dropdownMenu") + }) + } +) +``` + +## renderUI and uiOutput Example App {-} + + + +## renderUI and uiOutput Example HTML {-} + +```{r} +items = data.frame( + message = rep("A message", 2), + from = LETTERS[1:2], + time = rep("yesterday", 2), + color = rep("success", 2) +) + +lapply(seq_len(nrow(items)), function(i) { + items_i <- items[i, ] + bs4Dash::messageItem( + message = items_i$message, + from = items_i$from, + time = items_i$time, + color = items_i$color + ) +}) |> + c(badgeStatus = "danger", + type = "messages") |> + do.call(what = getExportedValue("bs4Dash", "dropdownMenu")) |> + as.character() |> + cat() +``` + + +## insertUI Process {-} + +1. `insertUI` sends a R message through `session$sendInsertUI`, via the **websocket**. + +```r +insertUI <- function(selector, + where = c("beforeBegin", "afterBegin", + "beforeEnd", "afterEnd"), + ui, + multiple = FALSE, + immediate = FALSE, + session = getDefaultReactiveDomain()) { + + force(selector) + force(ui) + force(session) + force(multiple) + if (missing(where)) where <- "beforeEnd" + where <- match.arg(where) + + callback <- function() { + session$sendInsertUI(selector = selector, + multiple = multiple, + where = where, + content = processDeps(ui, session)) + } + + if (!immediate) session$onFlushed(callback, once = TRUE) + else callback() +} +``` + +## insertUI Process {-} + +2. But before, `shiny:::processDeps(ui, session)` returns a list with **rendered HTML** and **dependency** objects. + +```r +processDeps <- function(tags, session) { + tags <- utils::getFromNamespace("tagify", "htmltools")(tags) + ui <- takeSingletons(tags, session$singletons, desingleton = FALSE)$ui + ui <- surroundSingletons(ui) + dependencies <- lapply( + resolveDependencies(findDependencies(ui, tagify = FALSE)), + createWebDependency + ) + names(dependencies) <- NULL + + list( + html = doRenderTags(ui), + deps = dependencies + ) +} +``` + +## insertUI Process {-} + +3. Sent html by Json to Javascript. + +```r +ShinySession <- R6Class( + 'ShinySession', + private = list( + sendInsertUI = function(selector, multiple, where, content) { + private$sendMessage( + `shiny-insert-ui` = list( + selector = selector, + multiple = multiple, + where = where, + content = content + ) + ) + }, + sendMessage = function(...) { + # This function is a wrapper for $write + msg <- list(...) + if (any_unnamed(msg)) { + stop("All arguments to sendMessage must be named.") + } + private$write(toJSON(msg)) + } + ) +) +``` + +## insertUI Process {-} + +4. The `MessageHandler` + + - Checks whether the provided selector has multiple DOM elements + - Calls `renderContent(html, el, dependencies)` which triggers + - `renderHtml(html, el, dependencies)` processes the provided HTML + + - Renders all given **dependencies** into the page’s head. + - **Inserts the HTML** into the page at the position provided in the insertUI where parameter (`insertAdjacentHTML`). + - Initializes any **input binds** them to the scope. + - **Sends** the value to the server _(to invalidate output/observers)_ and **connects/bounds** the outputs. + +```javascript + +addMessageHandler('shiny-insert-ui', function(message) { + var targets = $(message.selector); + if (targets.length === 0) { + // render the HTML and deps to a null target, so + // the side-effect of rendering the deps, singletons, + // and still occur + console.warn('The selector you chose ("' + message.selector + + '") could not be found in the DOM.'); + exports.renderHtml(message.content.html, $([]), message.content.deps); + } else { + targets.each(function (i, target) { + exports.renderContent(target, message.content, message.where); + return message.multiple; + }); + } + }); + +``` -- Book examples +## insertUI vs renderUI {-} + + +| **Feature** | `renderUI` | `insertUI` | +|-------------------------|-----------------------------------------------------------------------------|------------------------------------------------------------------------------| +| **Mechanism** | Re-renders the *entire* UI component and replaces it. | Inserts *new* UI elements _without_ modifying existing ones. | +| **Performance** | Slower (reprocesses all HTML/dependencies every time). | Faster (only processes new content). | +| **DOM Updates** | Forces full reflow (replaces container content). | Uses `insertAdjacentHTML` (minimal reflow). | +| **Dependencies** | Re-resolves dependencies (CSS/JS) on every render. | Processes dependencies once for new content. | +| **Use Case** | When the *entire* UI component must be rebuilt. | For *incremental* updates (adding/removing elements). | +| **State Preservation** | May reset inputs/outputs (re-initializes everything). | Preserves existing states (only binds new elements). | + +## insertUI Example Code {-} + +```r +library(shiny) +library(bs4Dash) + +ServerFunction = function(input, output, session) { + + observeEvent(input$add, { + insertUI( + selector = ".dropdown-menu > .dropdown-item .dropdown-header", + where = "afterEnd", #After the selector element itself + ui = messageItem( + message = paste("message", input$add), + image = dashboardUserImage, + from = "Divad Nojnarg", + time = "today", + color = "success" + ) + ) + }) +} +``` -## Other Shiny handlers +```{r} +bs4Dash::dropdownMenu(badgeStatus = "danger", + type = "messages") |> + as.character() |> + cat() +``` -### insertUI case +## insertUI Example Code {-} + +```r +library(shiny) +library(bs4Dash) + +ServerFunction = function(input, output, session) { + + observeEvent(input$add, { + insertUI( + selector = ".dropdown-menu > .dropdown-item .dropdown-header", + where = "afterEnd", #After the selector element itself + ui = messageItem( + message = paste("message", input$add), + image = dashboardUserImage, + from = "Divad Nojnarg", + time = "today", + color = "success" + ) + ) + }) +} +``` + +```{r} +bs4Dash::messageItem(message = paste("message", 1), + image = "https://adminlte.io/themes/v3/dist/img/user2-160x160.jpg", + from = "Divad Nojnarg", + time = "today", + color = "success") |> + as.character() |> + cat() +``` -- `insertUI` sends a R message through `session$sendInsertUI`, via the **websocket** -- content is processed by `shiny:::processDeps()` +## insertUI Example App {-} -- Finds and resolves any HTML dependency + -- For each dependency, makes sure the corresponding files can be accessed on the server with `createWebDependency()` and `addResourcePath()` -- Returns a list of the HTML element and dependencies. The HTML will be accessed by `message.content.html` and dependencies by `message.content.deps` +## insertUI Example App (Updated Selector) {-} -### Example +```{r} +#| echo: false -- If the item is inserted, the item counter as well as the dropdown text are not +create_note(text = "In the original example we were missing the division line between \"You have 0 messages\" and new message.
selector = \".dropdown-menu > .dropdown-divider\"") +``` -- We may fix that by adding extra `insertUI()` and `removeUI()` to replace those parts + -- order matters: ensure that **remove** happens before **insert** -- issue: a lot of server code! +## Updating counters Code {-} + +```{r} +#| echo: false + +create_note(text = "To update elements we need to remove them (using removeUI) and then inserting them back. Make sure that the observers for removeUI have a higher priority") +``` + +```r +ServerFunction <- function(input, output, session) { + + observeEvent(input$add, { + # remove old badge + removeUI(selector = ".badge-danger.navbar-badge") + + # remove old text counter + removeUI(selector = ".dropdown-item.dropdown-header") + + }, priority = 1) + + + + observeEvent(input$add, { + + # insert new badge + insertUI( + selector = "[data-toggle=\"dropdown\"]", + where = "beforeEnd", + ui = tags$span( + class = "badge badge-danger navbar-badge", + input$add + ) + ) + + # insert new text counter + insertUI( + selector = ".dropdown-menu", + where = "afterBegin", + ui = tags$span( + class = "dropdown-item dropdown-header", + sprintf("%s Items", input$add) + ) + ) + + # Insert message item + insertUI( + selector = ".dropdown-menu > .dropdown-divider", + where = "afterEnd", + ui = messageItem( + message = paste("message", input$add), + image = dashboardUserImage, + from = "Divad Nojnarg", + time = "today", + color = "success" + ) + ) + }) + +} +``` + -- issue: setting priorities in `observeEvent()` is a rather bad smell of poorly designed Shiny app +## Updating counters App {-} -## Custom handlers + -### Theory +## Custom handlers Diagram{-} -**session$sendCustomMessage(type, message)**. It works by pairing with the JS method **Shiny.AddCustomMessageHandler**, tightly linked by the type parameter +1. **R** sends a message using `session$sendCustomMessage(type, message)`. -- example +2. **JS** apply a defined action based on `Shiny.AddCustomMessageHandler`. -### Toward custom UI managment functions +![](image/15-optimize-custom-handlers/01-shiny-custom-message.png) -- we go back to the `bs4Dash::dropdownMenu()` issue +## Custom handlers Example Code {-} + +**add-message-item.js** + +```javascript +$(function() { + Shiny.addCustomMessageHandler('add-message-item', function(message) { + // since we do not re-render the dropdown, we must update its item counter + var $items = $('.dropdown-menu').find('.dropdown-item').length; + $('.dropdown-item.dropdown-header').html($items + ' Items'); + $('.nav-item.dropdown').find('.navbar-badge').html($items); + // convert string to HTML + var itemTag = $.parseHTML(message)[0]; + $(itemTag).insertAfter($('.dropdown-item.dropdown-header')); + }); +}); +``` + +## Custom handlers Example Code {-} + +**app.R** + +```r +library(shiny) +library(bs4Dash) + +insertMessageItem <- function(item, session = shiny::getDefaultReactiveDomain()) { + session$sendCustomMessage("add-message-item", message = as.character(item)) +} + +dropdownDeps <- function(){ + htmltools::htmlDependency(name = "bs4-dropdown", + version = "1.0.0", + src = c(file = "."), + script = "add-message-item.js") +} + +ServerFunction <- function(input, output, session) { + + observeEvent(input$add, { + insertMessageItem( + messageItem( + message = paste("message", input$add), + image = "https://adminlte.io/themes/v3/dist/img/user2-160x160.jpg", + from = "Divad Nojnarg", + time = "today", + color = "success" + ) + ) + }) + +} +``` -- create `insertMessageItem` with two parameters - - **item**, the HTML element we want to insert in the DOM - - **session**, used to send a message to JavaScript with `session$sendCustomMessage` -- We give it a **type**, that is `add-message-item`, to be able to identify it from JavaScript with `Shiny.addCustomMessageHandler` -- some JS stuff -- This solution significantly lightens the server code since everything may be done on the JS side in one step +## Custom handlers Example App {-} -### A chat system -- book example + ## Meeting Videos diff --git a/DESCRIPTION b/DESCRIPTION index 040c47b..25bbbb7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -15,6 +15,7 @@ Imports: bookdown, bs4Dash, DiagrammeR, + htmltools, httpuv, jsonlite, purrr, diff --git a/examples/chapter-15/01-handwriting-recognition/app.R b/examples/chapter-15/01-handwriting-recognition/app.R new file mode 100644 index 0000000..14f1b14 --- /dev/null +++ b/examples/chapter-15/01-handwriting-recognition/app.R @@ -0,0 +1,78 @@ +library(shiny) + +dropdownDeps <- function(){ + htmltools::htmlDependency(name = "handwriting", + version = "1.0.0", + src = c(file = "."), + script = "handwriting.canvas.js") +} + + +ui = fluidPage( + dropdownDeps(), + htmltools::HTML(' + +
+ + PenSize 3 +
+ +
+
+ Language: + +
+
+ + + + +
+

result:

+
+ + +
+') +) + + + +server <- function(input, output, session) { + + observeEvent(input$myinput,{ + removeUI(selector = "#result") + + insertUI(selector = "#result2", + ui = paste0(as.character(input$myinput), collapse = " "), + where = "beforeEnd") + }) + +} + +shinyApp(ui, server) diff --git a/examples/chapter-15/01-handwriting-recognition/handwriting.canvas.js b/examples/chapter-15/01-handwriting-recognition/handwriting.canvas.js new file mode 100644 index 0000000..7b1c8ea --- /dev/null +++ b/examples/chapter-15/01-handwriting-recognition/handwriting.canvas.js @@ -0,0 +1,248 @@ +(function(window, document) { + + // Establish the root object, `window` (`self`) in the browser, + // or `this` in some virtual machines. We use `self` + // instead of `window` for `WebWorker` support. + var root = typeof self === 'object' && self.self === self && self || this; + + // Create a safe reference to the handwriting object for use below. + var handwriting = function(obj) { + if (obj instanceof handwriting) return obj; + if (!(this instanceof handwriting)) return new handwriting(obj); + this._wrapped = obj; + }; + + root.handwriting = handwriting; + + handwriting.Canvas = function(cvs, lineWidth) { + this.canvas = cvs; + this.cxt = cvs.getContext("2d"); + this.cxt.lineCap = "round"; + this.cxt.lineJoin = "round"; + this.lineWidth = lineWidth || 3; + this.width = cvs.width; + this.height = cvs.height; + this.drawing = false; + this.handwritingX = []; + this.handwritingY = []; + this.trace = []; + this.options = {}; + this.step = []; + this.redo_step = []; + this.redo_trace = []; + this.allowUndo = false; + this.allowRedo = false; + cvs.addEventListener("mousedown", this.mouseDown.bind(this)); + cvs.addEventListener("mousemove", this.mouseMove.bind(this)); + cvs.addEventListener("mouseup", this.mouseUp.bind(this)); + cvs.addEventListener("touchstart", this.touchStart.bind(this)); + cvs.addEventListener("touchmove", this.touchMove.bind(this)); + cvs.addEventListener("touchend", this.touchEnd.bind(this)); + this.callback = undefined; + this.recognize = handwriting.recognize; + }; + /** + * [toggle_Undo_Redo description] + * @return {[type]} [description] + */ + handwriting.Canvas.prototype.set_Undo_Redo = function(undo, redo) { + this.allowUndo = undo; + this.allowRedo = undo ? redo : false; + if (!this.allowUndo) { + this.step = []; + this.redo_step = []; + this.redo_trace = []; + } + }; + + handwriting.Canvas.prototype.setLineWidth = function(lineWidth) { + this.lineWidth = lineWidth; + }; + + handwriting.Canvas.prototype.setCallBack = function(callback) { + this.callback = callback; + }; + + handwriting.Canvas.prototype.setOptions = function(options) { + this.options = options; + }; + + + handwriting.Canvas.prototype.mouseDown = function(e) { + // new stroke + this.cxt.lineWidth = this.lineWidth; + this.handwritingX = []; + this.handwritingY = []; + this.drawing = true; + this.cxt.beginPath(); + var rect = this.canvas.getBoundingClientRect(); + var x = e.clientX - rect.left; + var y = e.clientY - rect.top; + this.cxt.moveTo(x, y); + this.handwritingX.push(x); + this.handwritingY.push(y); + }; + + + handwriting.Canvas.prototype.mouseMove = function(e) { + if (this.drawing) { + var rect = this.canvas.getBoundingClientRect(); + var x = e.clientX - rect.left; + var y = e.clientY - rect.top; + this.cxt.lineTo(x, y); + this.cxt.stroke(); + this.handwritingX.push(x); + this.handwritingY.push(y); + } + }; + + handwriting.Canvas.prototype.mouseUp = function() { + var w = []; + w.push(this.handwritingX); + w.push(this.handwritingY); + w.push([]); + this.trace.push(w); + this.drawing = false; + if (this.allowUndo) this.step.push(this.canvas.toDataURL()); + }; + + + handwriting.Canvas.prototype.touchStart = function(e) { + e.preventDefault(); + this.cxt.lineWidth = this.lineWidth; + this.handwritingX = []; + this.handwritingY = []; + var de = document.documentElement; + var box = this.canvas.getBoundingClientRect(); + var top = box.top + window.pageYOffset - de.clientTop; + var left = box.left + window.pageXOffset - de.clientLeft; + var touch = e.changedTouches[0]; + touchX = touch.pageX - left; + touchY = touch.pageY - top; + this.handwritingX.push(touchX); + this.handwritingY.push(touchY); + this.cxt.beginPath(); + this.cxt.moveTo(touchX, touchY); + }; + + handwriting.Canvas.prototype.touchMove = function(e) { + e.preventDefault(); + var touch = e.targetTouches[0]; + var de = document.documentElement; + var box = this.canvas.getBoundingClientRect(); + var top = box.top + window.pageYOffset - de.clientTop; + var left = box.left + window.pageXOffset - de.clientLeft; + var x = touch.pageX - left; + var y = touch.pageY - top; + this.handwritingX.push(x); + this.handwritingY.push(y); + this.cxt.lineTo(x, y); + this.cxt.stroke(); + }; + + handwriting.Canvas.prototype.touchEnd = function(e) { + var w = []; + w.push(this.handwritingX); + w.push(this.handwritingY); + w.push([]); + this.trace.push(w); + if (this.allowUndo) this.step.push(this.canvas.toDataURL()); + }; + + handwriting.Canvas.prototype.undo = function() { + if (!this.allowUndo || this.step.length <= 0) return; + else if (this.step.length === 1) { + if (this.allowRedo) { + this.redo_step.push(this.step.pop()); + this.redo_trace.push(this.trace.pop()); + this.cxt.clearRect(0, 0, this.width, this.height); + } + } else { + if (this.allowRedo) { + this.redo_step.push(this.step.pop()); + this.redo_trace.push(this.trace.pop()); + } else { + this.step.pop(); + this.trace.pop(); + } + loadFromUrl(this.step.slice(-1)[0], this); + } + }; + + handwriting.Canvas.prototype.redo = function() { + if (!this.allowRedo || this.redo_step.length <= 0) return; + this.step.push(this.redo_step.pop()); + this.trace.push(this.redo_trace.pop()); + loadFromUrl(this.step.slice(-1)[0], this); + }; + + handwriting.Canvas.prototype.erase = function() { + this.cxt.clearRect(0, 0, this.width, this.height); + this.step = []; + this.redo_step = []; + this.redo_trace = []; + this.trace = []; + }; + + function loadFromUrl(url, cvs) { + var imageObj = new Image(); + imageObj.onload = function() { + cvs.cxt.clearRect(0, 0, this.width, this.height); + cvs.cxt.drawImage(imageObj, 0, 0); + }; + imageObj.src = url; + } + + handwriting.recognize = function(trace, options, callback) { + if (handwriting.Canvas && this instanceof handwriting.Canvas) { + trace = this.trace; + options = this.options; + callback = this.callback; + } else if (!options) options = {}; + var data = JSON.stringify({ + "options": "enable_pre_space", + "requests": [{ + "writing_guide": { + "writing_area_width": options.width || this.width || undefined, + "writing_area_height": options.height || this.width || undefined + }, + "ink": trace, + "language": options.language || "zh_TW" + }] + }); + var xhr = new XMLHttpRequest(); + xhr.addEventListener("readystatechange", function() { + if (this.readyState === 4) { + switch (this.status) { + case 200: + var response = JSON.parse(this.responseText); + var results; + if (response.length === 1) callback(undefined, new Error(response[0])); + else results = response[1][0][1]; + if (!!options.numOfWords) { + results = results.filter(function(result) { + return (result.length == options.numOfWords); + }); + } + if (!!options.numOfReturn) { + results = results.slice(0, options.numOfReturn); + } + Shiny.setInputValue('myinput', results, {priority: 'event'}); + break; + case 403: + callback(undefined, new Error("access denied")); + break; + case 503: + callback(undefined, new Error("can't connect to recognition server")); + break; + } + + + } + }); + xhr.open("POST", "https://www.google.com.tw/inputtools/request?ime=handwriting&app=mobilesearch&cs=1&oe=UTF-8"); + xhr.setRequestHeader("content-type", "application/json"); + xhr.send(data); + }; + +})(window, document); diff --git a/image/15-optimize-custom-handlers/01-shiny-custom-message.png b/image/15-optimize-custom-handlers/01-shiny-custom-message.png new file mode 100644 index 0000000..e6b4928 Binary files /dev/null and b/image/15-optimize-custom-handlers/01-shiny-custom-message.png differ diff --git a/style.css b/style.css new file mode 100644 index 0000000..1dc8b9e --- /dev/null +++ b/style.css @@ -0,0 +1,61 @@ +.callout { + border-left: 4px solid #2980b9; + border-top: 0.5px solid #2980b9; + border-bottom: 0.5px solid #2980b9; + border-right: 0.5px solid #2980b9; + background-color: white; + border-radius: 8px; + font-family: system-ui, sans-serif; + color: #2c3e50; + margin: 1em 0; + overflow: hidden; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); +} + +.callout-header { + display: flex; + align-items: center; + background-color: #e8f0fe; + padding: 0.2em 0.2em; + font-weight: bold; + border-bottom: 1px solid #d0e2f2; +} + +.callout-icon-container { + margin-right: 0.5em; + color: #2980b9; +} + +#text-margin { + margin-bottom: 0em; +} + +.callout-icon::before { + content: "i"; + font-weight: bold; + font-style: normal; + font-family: Georgia, serif; + display: inline-block; + width: 1em; + height: 1em; + text-align: center; +} + +.callout-title-container { + font-size: 1em; +} + +.callout-body { + padding: 1em; + font-size: 1em; + line-height: 1.5; +} + +.callout-body code { + background-color: white; + padding: 0.1em 0.3em; + border-radius: 3px; + font-family: Consolas, monospace; + font-size: 0.9em; + font-weight: bold; +}