jdtrat / shinymarkdown

Packages JavaScript's Toast UI Markdown Editor for use in R with Shiny.
Other
7 stars 4 forks source link

Current implementation - thoughts? #1

Open jdtrat opened 3 years ago

jdtrat commented 3 years ago

@nstrayer, what do you think of the current implementation? A simple app showcasing the features:

remotes::install_github("jdtrat/shinymarkdown")
library(shiny)
library(shinymarkdown)
ui <- fluidPage(
  use_shinymd(),
  marker(height = "600px", preview_style = "vertical"),
  actionButton("get_md", "Get Markdown"),
  actionButton("get_html", "Get HTML"),
  actionButton("hide_editor", "Hide the Editor"),
  actionButton("show_editor", "Show the Editor")
)

server <- function(input, output, session) {

  observeEvent(input$get_md, {print(get_markdown())})

  observeEvent(input$get_html, {print(get_html())})

  observeEvent(input$hide_editor, {hide_editor()})

  observeEvent(input$show_editor, {show_editor()})

}

shinyApp(ui, server)
jdtrat commented 3 years ago

Hi Nick! The above setup is deprecated in favor of a more traditional Shiny input setup. Please see below:


library(shiny)
library(shinymarkdown)

ui <- fluidPage(
  mdInput(inputId = "editor1", height = "300px", hide_mode_switch = F),
  mdInput(inputId = "editor2", height = "300px", hide_mode_switch = F),
  actionButton("hide1", "Hide the First Editor"),
  actionButton("show1", "Show the First Editor"),
  actionButton("hide2", "Hide the Second Editor"),
  actionButton("show2", "Show the Second Editor")
)

server <- function(input, output, session) {

  # print the markdown version of what is typed - editor 1
  observe({print(input$editor1_markdown)})

  # print the html version of what is typed - editor 1
  observe({print(input$editor1_html)})

  # print the markdown version of what is typed - editor 2
  observe({print(input$editor2_markdown)})

  # print the html version of what is typed - editor 2
  observe({print(input$editor2_html)})

  # hide the first editor
  observeEvent(input$hide1, {hide_editor(.id = "editor1")})

  # show the first editor
  observeEvent(input$show1, {show_editor(.id = "editor1")})

  # hide the second editor
  observeEvent(input$hide2, {hide_editor(.id = "editor2")})

  # show the second editor
  observeEvent(input$show2, {show_editor(.id = "editor2")})

}

shinyApp(ui, server)
nstrayer commented 3 years ago

Sorry for my super-slow response on this!

I think this looks good! My one hesitation would be sending data back and forth after every update will cause your app to get super slow if someone has written something of any appreciable size. To deal with this i'd say adding a debounce message and also maybe adding the option for a "click to send to Shiny" type button that only sends when the user is happy with it.

Say you had a plot or other slow output that was dependent on the md input, your whole app would freeze at almost every keystroke.

Here's a demo with both the current and w/ debounce way:

library(shiny)
library(shinymarkdown)
library(ggplot2)

ui <- fluidPage(
  mdInput(inputId = "editor1", height = "300px", hide_mode_switch = F),
  plotOutput("text_plot")
)

server <- function(input, output, session) {

  # Start with the "normal" mode and type at full speed in the input
  # You will notice after a while the app needs to play catchup
  text_from_md <- reactive({ input$editor1_markdown })

  # # Uncomment this one and try again with the debouncing
  # # The app waits until the user has stopped writing for a while before acting
  # # avoiding it processing a bunch of very similar events in a row
  # text_from_md <- debounce(
  #   reactive({ input$editor1_markdown }),
  #   millis = 1000 # Wait a whole second before submitting
  # )

  output$text_plot <- renderPlot({
    ggplot() +
      annotate(
        geom= "text",
        x = 0,
        y = 0,
        label = text_from_md()
      )
  })
}

shinyApp(ui, server)
jdtrat commented 3 years ago

No worries! I see why a debounce would be beneficial. Are you thinking that should just be an argument of mdInput? Maybe refresh_rate = 1000 as a default and then have it implemented on the JS side?

In terms of an option for click to send to shiny, what do you think the best way to do that is? Maybe we could have users set refresh_rate = “manual” and not set the input unless get_markdown() and get_html() functions are called? Those functions are just custom message handlers from the original implementation above.

nstrayer commented 3 years ago

I think doing debounce in JS vs R doesn't make a huge difference. Doing in JS will save the data-transfer but I dont think that will be a big hindrance as nothing is too big. Whatever you think will be easier to maintain.

I think the idea of refresh_rate = "manual" is a good one. To make the API as simple for the end-user as possible I'd say you can take care of the button building and then just have the returned reactive value not trigger until that button is pressed. The easiest way would be to use JS to build a button directly and then not fire your to-shiny message until that button is pressed, but you could also manage it on the R side as well. Could be as simple as adding the button with Shiny UI functions and then in the HTMLWidget creation look for the button, if it exists you go into the manual submission mode, otherwise stay at the same behavior.

That's a wall of technical text so let me know if you need any clarification!

jdtrat commented 3 years ago

Hi Nick! I think using {whisker} to render the JS, it's just as easy to implement debouncing in JS. #6 is a PR with what I'm thinking. I've included sample code and a screenshot there, but below is an overview. I'm not sure it's the most efficient way to do things, and I'd greatly appreciate your thoughts on it! I do want to note, the JS below is included directly in the HTML's body with the script defining the editor's instance.

When refresh_rate is numeric, the JS will include:

// define debounce function
const debounce = function(func, delay) {
  let timeout;

  return function executed(...args) {
    const later = function() {
      clearTimeout(timeout);
      func(...args);
    };

    clearTimeout(timeout);
    timeout = setTimeout(later, delay);

  };

};

// define getEditorContents as a debounced function that sets the Shiny input values appropriately
var getEditorContents = debounce(function() {
    Shiny.setInputValue("{{inputId}}" + "_markdown", {{inputId}}_editor.getMarkdown());
    Shiny.setInputValue("{{inputId}}" + "_html", {{inputId}}_editor.getHtml());
}, {{refresh_rate}});

/* When someone types in the editor, make the markdown and HTML available
   as the Shiny/R inputs "shinymd_markdown" and "shinymd_html", respectively.
   This occurs after the debounce time to improve performance of Shiny apps.*/
$('#' + '{{inputId}}' + '_editor').on('keyup', getEditorContents);

When refresh_rate = "manual", the JS will include:

// create var switchSection which isolates the specific editor's footer button div
switchSection = $('#' + '{{inputId}}' + '_editor .te-mode-switch-section')[0];

// add a new button with sendShiny as its id
$(switchSection).append('<button id = "sendShiny"> Send to Shiny </button>');

// Add the button class and change some CSS defaults
$("#sendShiny").addClass("te-switch-button").css({"height": "19px", "width": "120px"});

// when the button is pressed add the active class and send the Shiny input values
$("#sendShiny").on("mousedown", function() {
  $(this).addClass("active");
  Shiny.setInputValue("{{inputId}}" + "_markdown", {{inputId}}_editor.getMarkdown());
  Shiny.setInputValue("{{inputId}}" + "_html", {{inputId}}_editor.getHtml());
});

// when the button is done being pressed remove the active class
$("#sendShiny").on("mouseup", function() {$(this).removeClass("active");});