From 386d67af64eab177853146dec3622a7483a871a1 Mon Sep 17 00:00:00 2001 From: detule Date: Wed, 12 Jun 2019 12:26:19 -0400 Subject: [PATCH 1/4] Refactor shinyAce -> Rather than building a script and inserting it in the HTML body every time an editor is created, we move the element creation logic in the "initialize" step of the shiny widget. -> [R] functions (aceEditor, updateAceEditor) create a JSON payload only that is attached to the appropriate element. -> Shared Javascript code-path for widget initialization, and widget update (shinyAce.js: updateEditor). -> Make JS <-> server.R communication fully module friendly (selectionId, cursorId, hotkeys). These are all now reported with a prefix "inputId_" (similar to shinyAce_hint). This is a breaking change. -> Add couple of examples. --- DESCRIPTION | 2 +- R/ace-editor.R | 266 ++++----------------- R/js-quote.R | 12 - R/update-ace-editor.R | 9 + inst/examples/05-hotkeys/server.R | 4 +- inst/examples/05-hotkeys/ui.R | 2 +- inst/examples/09-selectionId/server.R | 43 ++++ inst/examples/09-selectionId/ui.R | 25 ++ inst/examples/10-modules/editorModule.R | 62 +++++ inst/examples/10-modules/global.R | 2 + inst/examples/10-modules/server.R | 5 + inst/examples/10-modules/ui.R | 11 + inst/www/shinyAce.js | 294 +++++++++++++++++------- man/jsQuote.Rd | 17 -- 14 files changed, 422 insertions(+), 332 deletions(-) delete mode 100644 R/js-quote.R create mode 100644 inst/examples/09-selectionId/server.R create mode 100644 inst/examples/09-selectionId/ui.R create mode 100644 inst/examples/10-modules/editorModule.R create mode 100644 inst/examples/10-modules/global.R create mode 100644 inst/examples/10-modules/server.R create mode 100644 inst/examples/10-modules/ui.R delete mode 100644 man/jsQuote.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 6ab61a8..b388fed 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -23,4 +23,4 @@ Suggests: dplyr (>= 0.7.4) BugReports: https://github.com/trestletech/shinyAce/issues Encoding: UTF-8 -RoxygenNote: 6.1.0 +RoxygenNote: 6.1.1 diff --git a/R/ace-editor.R b/R/ace-editor.R index d447605..1e057de 100644 --- a/R/ace-editor.R +++ b/R/ace-editor.R @@ -105,7 +105,7 @@ #' @export aceEditor <- function( outputId, value, mode, theme, - vimKeyBinding = FALSE, readOnly = FALSE, height = "400px", fontSize = 12, + vimKeyBinding = FALSE, readOnly = FALSE, height = "400px", fontSize = 12, debounce = 1000, wordWrap = FALSE, showLineNumbers = TRUE, highlightActiveLine = TRUE, selectionId = NULL, cursorId = NULL, hotkeys = NULL, @@ -116,233 +116,59 @@ aceEditor <- function( showInvisibles = FALSE, setBehavioursEnabled = TRUE, autoScrollEditorIntoView = FALSE, maxLines = NULL, minLines = NULL ) { - - editorVar <- paste0("editor__", sanitizeId(outputId)) - js <- paste("var ", editorVar," = ace.edit('", outputId, "');", sep = "") - if (!missing(theme)) { - js <- paste(js, "", editorVar, ".setTheme('ace/theme/", theme, "');", sep = "") - } - if (vimKeyBinding) { - js <- paste(js, "", editorVar, ".setKeyboardHandler('ace/keyboard/vim');", sep = "") - } - if (!missing(mode)) { - js <- paste(js, "", editorVar, ".getSession().setMode('ace/mode/", mode,"');", sep = "") - } - if (!missing(value)) { - js <- paste(js, "", editorVar, ".setValue(", jsQuote(value), ", -1);", sep = "") - } - if (!showLineNumbers) { - js <- paste(js, "", editorVar, ".renderer.setShowGutter(false);", sep = "") - } - if (!highlightActiveLine) { - js <- paste(js, "", editorVar, ".setHighlightActiveLine(false);", sep = "") - } - if (readOnly) { - js <- paste(js, "", editorVar, ".setReadOnly(", jsQuote(readOnly), ");", sep = "") - } - if (!is.null(fontSize) && !is.na(as.numeric(fontSize))) { - js <- paste(js, "document.getElementById('", outputId, "').style.fontSize='", - as.numeric(fontSize), "px'; ", sep = "") - } + + escapedId <- gsub("\\.", "\\\\\\\\.", outputId) + escapedId <- gsub("\\:", "\\\\\\\\:", escapedId) + payloadLst <- + list( + id = escapedId, + vimKeyBinding = vimKeyBinding, + readOnly = readOnly, + wordWrap = wordWrap, + showLineNumbers = showLineNumbers, + highlightActiveLine = highlightActiveLine, + selectionId = selectionId, + cursorId = cursorId, + hotkeys = hotkeys, + autoComplete = match.arg(autoComplete), + autoCompleters = I(autoCompleters), + autoCompleteList = autoCompleteList, + tabSize = tabSize, + useSoftTabs = useSoftTabs, + showInvisibles = showInvisibles, + setBehavioursEnabled = setBehavioursEnabled, + autoScrollEditorIntoView = autoScrollEditorIntoView, + maxLines = maxLines, + minLines = minLines + ) + + if(!missing(value)) payloadLst$value <- value + if(!missing(mode)) payloadLst$mode <- mode + if(!missing(theme)) payloadLst$theme <- theme + if(!is.null(fontSize) && !is.na(as.numeric(fontSize))) + payloadLst$fontSize <- fontSize + if(!is.null(debounce) && !is.na(as.numeric(debounce))) + payloadLst$debounce <- debounce if (!is.null(debounce) && !is.na(as.numeric(debounce))) { - # I certainly hope there's a more reasonable way to compare + # I certainly hope there's a more reasonable way to compare # versions with an extra field in them... re <- regexpr("^\\d+\\.\\d+(\\.\\d+)?", utils::packageVersion("shiny")) shinyVer <- substr(utils::packageVersion("shiny"), 0, attr(re, "match.length")) minorVer <- as.integer(substr(utils::packageVersion("shiny"), - attr(re, "match.length") + 2, - nchar(utils::packageVersion("shiny")))) + attr(re, "match.length") + 2, + nchar(utils::packageVersion("shiny")))) comp <- utils::compareVersion(shinyVer, "0.9.1") if (comp < 0 || (comp == 0 && minorVer < 9004)) { warning("Shiny version 0.9.1.9004 required to use input debouncing in shinyAce.") } - js <- paste(js, "$('#", outputId ,"').data('debounce',", debounce,");", sep = "") - } - - if (wordWrap) { - js <- paste(js, "", editorVar,".getSession().setUseWrapMode(true);", sep = "") - } - - # https://learn.jquery.com/using-jquery-core/faq/how-do-i-select-an-element-by-an-id-that-has-characters-used-in-css-notation/ - escapedId <- gsub("\\.", "\\\\\\\\.", outputId) - escapedId <- gsub("\\:", "\\\\\\\\:", escapedId) - js <- paste(js, "$('#", escapedId, "').data('aceEditor',", editorVar, ");", sep = "") - - if (!is.null(selectionId)) { - selectJS <- paste("", editorVar, ".getSelection().on(\"changeSelection\", function() { - Shiny.onInputChange(\"", selectionId, - "\",", editorVar, ".getCopyText());})", - sep = "") - js <- paste(js, selectJS, sep = "") - } - - if (!is.null(cursorId)) { - curJS <- paste("\n", editorVar, ".getSelection().on(\"changeCursor\", function() { - Shiny.onInputChange(\"", cursorId, - "\",", editorVar, ".selection.getCursor() );}\n);", - sep = "") - js <- paste(js, curJS, sep = "") - } - - for (i in seq_along(hotkeys)) { - shortcut = hotkeys[[i]] - if (is.list(shortcut)) { - shortcut = paste0(names(shortcut), ": '", shortcut, "'", collapse = ", ") - } else { - shortcut = paste0("win: '", shortcut, "', mac: '", shortcut, "'") - } - - id = names(hotkeys)[i] - code = paste0(" - ", editorVar,".commands.addCommand({ - name: '", id,"', - bindKey: {", shortcut,"}, - exec: function(", editorVar,") { - - var selection = ", editorVar, ".session.getTextRange(); - var range = ", editorVar, ".selection.getRange(); - var imax = ", editorVar, ".session.getLength() - range.end.row; - - if(selection === '') { - var i = 1; - var line = ", editorVar, ".session.getLine(range.end.row); - var next_line = ", editorVar, ".session.getLine(range.end.row + i); - - if (/^```\\{.*\\}\\s*$/.test(line)) { - // run R-code chunk - while(/\\n```\\s*$/.test(line) === false & i < imax + 1) { - i++; - line = line.concat('\\n', next_line); - next_line = ", editorVar, ".session.getLine(range.end.row + i); - // console.log(next_line, i, imax); - } - if (i === imax + 1) { - line = '

Code chunk not properly closed. Code chunks must end in ` ` `

'; - } - } else if (/^\\$\\$\\s*$/.test(line)) { - // evaluate equation - while(/\\n\\$\\$\\s*$/.test(line) === false & i < imax + 1) { - i++; - line = line.concat('\\n', next_line); - next_line = ", editorVar, ".session.getLine(range.end.row + i); - } - if (i === imax + 1) { - line = '

Equation not properly closed. Display equations must start and end with $$

'; - } - } else if (/(\\(|\\{|\\[)\\s*$/.test(line)) { - ", editorVar, ".navigateLineEnd(); - ", editorVar, ".jumpToMatching(); - match_line = ", editorVar, ".selection.getCursor(); - if (match_line.row === range.end.row) { - line = '#### Bracket not properly closed. Fix and try again'; - } else { - line = ", editorVar, ".session.getLines(range.end.row, match_line.row).join('\\n'); - i = match_line.row - range.end.row + 1 - } - } else { - rexpr = /(%>%|\\+|\\-|\\,)\\s*$/; - rxeval = rexpr.test(line); - while((rxeval | /^\\s*(\\#|$)/.test(next_line)) & i < imax) { - rxeval = rexpr.test(line); - if (rxeval | /^\\s*(\\}|\\))/.test(next_line)) { - line = line.concat('\\n', next_line); - } - i++; - next_line = ", editorVar, ".session.getLine(range.end.row + i); - // console.log(next_line, i, imax) - } - } - ", editorVar, ".gotoLine(range.end.row + i + 1); - if (line === '') { - line = ' '; // ensure whole report is not rendered - } - } - - Shiny.onInputChange(\"", id, - "\",{ - editorId: '", outputId,"', - selection: selection, - range: range, - line: line, - randNum: Math.random() - }); - }, - readOnly: true // false if this command should not apply in readOnly mode - }); - ") - js <- paste0(js, code) - } - - autoComplete <- match.arg(autoComplete) - if (autoComplete != "disabled") { - js <- paste(js, "", editorVar, ".setOption('enableBasicAutocompletion', true);", sep = "") - } - if (autoComplete == "live") { - js <- paste(js, "", editorVar, ".setOption('enableLiveAutocompletion', true);", sep = "") - } - - if (length(autoCompleters) > 0) { - if (sum(autoCompleters %in% c("snippet", "text", "static", "keyword")) > 0) { - js <- paste(js, 'var langTools = ace.require("ace/ext/language_tools");') - js <- paste(js, "", editorVar, ".completers = [];", sep = "") - if ("snippet" %in% autoCompleters) { - js <- paste(js, "", editorVar, ".completers.push(langTools.snippetCompleter);", sep = "") - } - if ("text" %in% autoCompleters) { - js <- paste(js, "", editorVar, ".completers.push(langTools.textCompleter);", sep = "") - } - if ("keyword" %in% autoCompleters) { - js <- paste(js, "", editorVar, ".completers.push(langTools.keywordCompleter);", sep = "") - } - if ("static" %in% autoCompleters) { - code <- 'var staticCompleter = { - getCompletions: function(editor, session, pos, prefix, callback) { - var comps = $("#" + editor.container.id).data("auto-complete-list"); - if(comps) { - var words = []; - Object.keys(comps).forEach(function(key) { - var comps_key = comps[key]; - if (!Array.isArray(comps[key])) { - comps_key = [comps_key]; - } - words = words.concat(comps_key.map(function(d) { - return {name: d, value: d, meta: key}; - })); - }); - callback(null, words); - } - } - }; - langTools.addCompleter(staticCompleter);' - js <- paste0(js, code) - js <- paste(js, "", editorVar, ".completers.push(staticCompleter);", sep = "") - } - } - } else { - js <- paste(js, "", editorVar, ".completers = [];", sep = "") - } - - if (!useSoftTabs) { - js <- paste(js, "", editorVar, ".setOption('useSoftTabs', false);", sep = "") - } - js <- paste(js, "", editorVar, ".setOption('tabSize', ", tabSize, ");", sep = "") - if (showInvisibles) { - js <- paste(js, "", editorVar, ".setOption('showInvisibles', true);", sep = "") - } - if (!setBehavioursEnabled) { - js <- paste(js, "", editorVar, ".setBehavioursEnabled(false);", sep = "") - } - - if (autoScrollEditorIntoView) { - js <- paste(js, "", editorVar, ".setOption('autoScrollEditorIntoView', true);", sep = "") - if (!is.null(maxLines)) { - js <- paste(js, "", editorVar, ".setOption('maxLines', ", maxLines, ");", sep = "") - } - if (!is.null(minLines)) { - js <- paste(js, "", editorVar, ".setOption('minLines', ", minLines, ");", sep = "") - } - } - + payloadLst$debounce <- debounce + } + # Filter out any elements of the list that are NULL + # In the javascript code we use ".hasOwnProperty" to test whether a property + # should be set, and all of our properties are such that a javascript value of + # `null` does not make sense. + payloadLst <- Filter(f = function(y) !is.null(y), x = payloadLst) + payload <- jsonlite::toJSON(payloadLst, null = "null", auto_unbox = TRUE) tagList( singleton(tags$head( initResourcePaths(), @@ -362,7 +188,7 @@ aceEditor <- function( style = paste("height:", validateCssUnit(height)), `data-auto-complete-list` = jsonlite::toJSON(autoCompleteList) ), - tags$script(type = "text/javascript", HTML(js)) + tags$script(type = "application/json", `data-for` = escapedId, HTML(payload)) ) } diff --git a/R/js-quote.R b/R/js-quote.R deleted file mode 100644 index 11af22d..0000000 --- a/R/js-quote.R +++ /dev/null @@ -1,12 +0,0 @@ -#' Escape a JS String -#' -#' Escape a String to be sent to JavaScript -#' @param text The text to escape -#' -#' @author Jeff Allen \email{jeff@@trestletech.com} -jsQuote <- function(text){ - toReturn <- shQuote(text) - toReturn <- gsub('\f', '\\\\f', toReturn) - toReturn <- gsub('\r', '\\\\r', toReturn) - gsub('\n', '\\\\n', toReturn) -} \ No newline at end of file diff --git a/R/update-ace-editor.R b/R/update-ace-editor.R index 378d880..41c1241 100644 --- a/R/update-ace-editor.R +++ b/R/update-ace-editor.R @@ -53,6 +53,15 @@ updateAceEditor <- function( if (missing(session) || missing(editorId)) { stop("Must provide both a session and an editorId to update Ace editor settings") } + if(!all(autoComplete %in% c("disabled", "enabled", "live"))) + stop("updateAceEditor: Incorrectly formatted autoComplete parameter") + if(!all(border %in% c("normal", "alert", "flash"))) + stop("updateAceEditor: Incorrectly formatted border parameter") + if( + !is.null(autoCompleters) && + !all(autoCompleters %in% c("snippet", "text", "keyword", "static", "rlang")) + ) + stop("updateAceEditor: Incorrectly formatted autoCompleters parameter") theList <- list(id = editorId) diff --git a/inst/examples/05-hotkeys/server.R b/inst/examples/05-hotkeys/server.R index 8dbc5b8..73efb17 100644 --- a/inst/examples/05-hotkeys/server.R +++ b/inst/examples/05-hotkeys/server.R @@ -8,12 +8,12 @@ shinyServer(function(input, output, session) { vals <- reactiveValues(log = "") observe({ - input$runKey + input$ace_runKey isolate(vals$log <- paste(vals$log, renderLogEntry("Run Key"), sep="\n")) }) observe({ - input$helpKey + input$ace_helpKey isolate(vals$log <- paste(vals$log, renderLogEntry("Help Key"), sep="\n")) }) diff --git a/inst/examples/05-hotkeys/ui.R b/inst/examples/05-hotkeys/ui.R index eea3c68..4728667 100644 --- a/inst/examples/05-hotkeys/ui.R +++ b/inst/examples/05-hotkeys/ui.R @@ -25,4 +25,4 @@ shinyUI( mac="CMD-ENTER|CMD-SHIFT-ENTER") )) , width=6) -)) \ No newline at end of file +)) diff --git a/inst/examples/09-selectionId/server.R b/inst/examples/09-selectionId/server.R new file mode 100644 index 0000000..d84fb5d --- /dev/null +++ b/inst/examples/09-selectionId/server.R @@ -0,0 +1,43 @@ +library(shiny) +library(shinyAce) + +#' Define server logic required to generate simple ace editor +#' @author Jeff Allen \email{jeff@@trestletech.com} +shinyServer(function(input, output, session) { + + vals <- reactiveValues(log = "") + + observe({ + valC <- input$ace_cursor + if (!is.null(valC) && length(valC)) { + isolate(vals$log <- paste( + vals$log, + renderLogEntry( + paste0("Cursor moved to row: ", valC$row, " col: ", valC$col) + ), + sep="\n") + ) + } + }) + + observe({ + valS <- input$ace_selection + isolate({ + if (!is.null(valS) && valS != "") { + vals$log <- paste( + vals$log, + renderLogEntry(paste0("Selection: ", valS)), + sep="\n") + } + }) + }) + + output$log <- renderText({ + vals$log + }) +}) + +renderLogEntry <- function(entry){ + paste0(date(), " - ", entry) +} + diff --git a/inst/examples/09-selectionId/ui.R b/inst/examples/09-selectionId/ui.R new file mode 100644 index 0000000..f5ada94 --- /dev/null +++ b/inst/examples/09-selectionId/ui.R @@ -0,0 +1,25 @@ +library(shiny) +library(shinyAce) + +modes <- getAceModes() + +themes <- getAceThemes() + +#' Define UI for application that demonstrates a simple Ace editor +#' @author Jeff Allen \email{jeff@@trestletech.com} +shinyUI( + pageWithSidebar( + # Application title + headerPanel("ShinyAce with Hotkeys"), + + sidebarPanel( + helpText(HTML("

AceEditor with `cursorId`, and `selectionId`: observe the events being reported as you either move the cursor in the box or make a text selection.

+

Created using shinyAce.")), + tags$hr(), + verbatimTextOutput("log") + , width=6), + + mainPanel( + aceEditor("ace", value="Here's some text in the editor.", cursorId = "cursor", selectionId = "selection") + , width=6) +)) diff --git a/inst/examples/10-modules/editorModule.R b/inst/examples/10-modules/editorModule.R new file mode 100644 index 0000000..06b5e17 --- /dev/null +++ b/inst/examples/10-modules/editorModule.R @@ -0,0 +1,62 @@ +editorUI <- function(id) { + ns <- NS(id) + + fluidRow( + shiny::column(width = 4, + shinyAce::aceEditor( + ns("code"), + mode = "r", + autoComplete = "live", + autoCompleters = "rlang", + hotkeys = list( + runKey=list( + win = "Ctrl-Enter", + mac = "CMD-ENTER" + ) + ) + ) + ), # column + shiny::column(width = 8, + tags$div( + style = "overflow-y:scroll; max-height: 300px;", + verbatimTextOutput(ns("codeOutput")) + ) + ) # column + ) # row + +} + +editorSERVER <- function( + input, + output, + session +) { + + ns <- session$ns + shinyAce::aceAutocomplete("code") + tmpFile <- tempfile() + + output$codeOutput <- renderPrint({ + val <- input$code_runKey + if(is.null(val)) { + return( + "Execute [R] chunks with Ctrl/Cmd-Enter" + ) + } + + selectionLocal <- val$selection + lineLocal <- val$line + if(val$selection != "") { + toExecute <- selectionLocal + } else { + shiny::validate( + need(!is.null(lineLocal), "Unable to find execution text") + ) + toExecute <- lineLocal + } + writeLines(toExecute, con = tmpFile) + return(source(tmpFile, echo = TRUE, local = TRUE)) + + }) + +} diff --git a/inst/examples/10-modules/global.R b/inst/examples/10-modules/global.R new file mode 100644 index 0000000..3df98f2 --- /dev/null +++ b/inst/examples/10-modules/global.R @@ -0,0 +1,2 @@ +library(shinyAce) +source("editorModule.R") diff --git a/inst/examples/10-modules/server.R b/inst/examples/10-modules/server.R new file mode 100644 index 0000000..d7662be --- /dev/null +++ b/inst/examples/10-modules/server.R @@ -0,0 +1,5 @@ +function(input, output, session) { + shiny::callModule(editorSERVER,"termone") + shiny::callModule(editorSERVER,"termtwo") + shiny::callModule(editorSERVER,"termthree") +} diff --git a/inst/examples/10-modules/ui.R b/inst/examples/10-modules/ui.R new file mode 100644 index 0000000..e66f500 --- /dev/null +++ b/inst/examples/10-modules/ui.R @@ -0,0 +1,11 @@ +navbarPage("Multiple [R] Terminals Demo", + tabPanel("Terminal 1", + editorUI("termone") + ), + tabPanel("Terminal 2", + editorUI("termtwo") + ), + tabPanel("Terminal 3", + editorUI("termthree") + ) +) diff --git a/inst/www/shinyAce.js b/inst/www/shinyAce.js index fe9db26..95f3c2e 100644 --- a/inst/www/shinyAce.js +++ b/inst/www/shinyAce.js @@ -1,36 +1,5 @@ (function(){ - var shinyAceInputBinding = new Shiny.InputBinding(); - $.extend(shinyAceInputBinding, { - find: function(scope) { - return $(scope).find(".shiny-ace"); - }, - getValue: function(el) { - return($(el).data('aceEditor').getValue()); - }, - setValue: function(el, value) { - //TODO - }, - subscribe: function(el, callback) { - $(el).data('aceChangeCallback', function(e) { - callback(true); - }); - - $(el).data('aceEditor').getSession().addEventListener("change", - $(el).data('aceChangeCallback') - ); - }, - unsubscribe: function(el) { - $(el).data('aceEditor').getSession().removeEventListener("change", - $(el).data('aceChangeCallback')); - }, - getRatePolicy: function(el){ - return ({policy: 'debounce', delay: $(el).data('debounce') || 1000 }); - } - }); - - Shiny.inputBindings.register(shinyAceInputBinding); - var langTools = ace.require("ace/ext/language_tools"); var staticCompleter = { getCompletions: function(editor, session, pos, prefix, callback) { @@ -64,56 +33,175 @@ // nonce causes autocomplete event to trigger // on R side even if Ctrl-Space is pressed twice // with the same linebuffer and cursorPosition - nonce: Math.random() + nonce: Math.random() }); // store callback for dynamic completion $('#' + inputId).data('autoCompleteCallback', callback); } - // TODO: add option to include optional getDocTooltip for suggestion context + // TODO: add option to include optional getDocTooltip for suggestion context }; langTools.addCompleter(rlangCompleter); - Shiny.addCustomMessageHandler('shinyAce', function(data) { - var id = data.id; - var $el = $('#' + id); - var editor = $el.data('aceEditor'); - - if (data.theme){ + function updateEditor(el, data) { + if (typeof $(el).data('aceEditor') !== 'undefined') + var editor = $(el).data('aceEditor'); + else + var editor = ace.edit(el); + + if (data.hasOwnProperty('fontSize')) { + el.style.fontSize = data.fontSize + 'px'; + } + + if (data.hasOwnProperty('theme')) { editor.setTheme("ace/theme/" + data.theme); } - - if (data.mode){ - editor.getSession().setMode("ace/mode/" + data.mode); + + if (data.hasOwnProperty('mode')) { + editor.getSession().setMode('ace/mode/' + data.mode); + } + + if (data.hasOwnProperty('value')) { + editor.setValue(data.value, -1); } - - if (data.value !== undefined){ - editor.getSession().setValue(data.value, -1); + + if (data.hasOwnProperty("selectionId")) { + editor.getSelection().on("changeSelection", function() { + Shiny.onInputChange(el.id + "_" + data.selectionId, editor.getCopyText()); + }) } - - if (data.hasOwnProperty('readOnly')) { - editor.setReadOnly(data.readOnly); + + if (data.hasOwnProperty("cursorId")) { + editor.getSelection().on("changeCursor", function() { + Shiny.onInputChange(el.id + "_" + data.cursorId, editor.selection.getCursor()); + }) } - - if (data.fontSize) { - document.getElementById(id).style.fontSize = data.fontSize + 'px'; + + if(data.hasOwnProperty("hotkeys")) { + Object.keys(data.hotkeys).forEach(function(key) { + editor.commands.addCommand({ + name: key, + bindKey: data.hotkeys[key], + exec: function(editor) { + var selection = editor.session.getTextRange(); + var range = editor.selection.getRange(); + var imax = editor.session.getLength() - range.end.row; + var inputId = editor.container.id; + if(selection === "") { + var i = 1; + var line = editor.session.getLine(range.end.row); + var next_line = editor.session.getLine(range.end.row + i); + + if (/^```\{.*\}\s*$/.test(line)) { + // run R-code chunk + while(/\n```\s*$/.test(line) === false & i < imax + 1) { + i++; + line = line.concat('\n', next_line); + next_line = editor.session.getLine(range.end.row + i); + // console.log(next_line, i, imax); + } + if (i === imax + 1) { + line = '

Code chunk not properly closed. Code chunks must end in ` ` `

'; + } + } else if (/^\$\$\s*$/.test(line)) { + // evaluate equation + while(/\n\$\$\s*$/.test(line) === false & i < imax + 1) { + i++; + line = line.concat('\n', next_line); + next_line = editor.session.getLine(range.end.row + i); + } + if (i === imax + 1) { + line = '

Equation not properly closed. Display equations must start and end with $$

'; + } + } else if (/(\(|\{|\[)\s*$/.test(line)) { + editor.navigateLineEnd(); + editor.jumpToMatching(); + match_line = editor.selection.getCursor(); + if (match_line.row === range.end.row) { + line = '#### Bracket not properly closed. Fix and try again'; + } else { + line = editor.session.getLines(range.end.row, match_line.row).join('\n'); + i = match_line.row - range.end.row + 1 + } + } else { + rexpr = /(%>%|\+|\-|\,)\s*$/; + rxeval = rexpr.test(line); + while((rxeval | /^\s*(\#|$)/.test(next_line)) & i < imax) { + rxeval = rexpr.test(line); + if (rxeval | /^\s*(\}|\))/.test(next_line)) { + line = line.concat('\n', next_line); + } + i++; + next_line = editor.session.getLine(range.end.row + i); + // console.log(next_line, i, imax) + } + } + editor.gotoLine(range.end.row + i + 1); + if (line === '') { + line = ' '; // ensure whole report is not rendered + } + } + var shinyEvent = { + editorId: inputId, + selection: selection, + range: range, + line: line, + randNum: Math.random() + }; + Shiny.onInputChange(inputId + "_" + key, shinyEvent); + }, // exec end + readOnly: true // false if this command should not apply in readOnly mode + }); //editor.addCommand end + }); // forEach end } - - if (data.hasOwnProperty('wordWrap')) { + + if (data.hasOwnProperty("debounce")) { + $(el).data("debounce", data.debounce); + } + + if (data.hasOwnProperty("vimKeyBinding") && data.vimKeyBinding === true) { + editor.setKeyboardHandler("ace/keyboard/vim"); + } + + if(data.hasOwnProperty("showLineNumbers") && data.showLineNumbers === false) { + editor.renderer.setShowGutter(false); + } + + if(data.hasOwnProperty("highlightActiveLine") && data.highlightActiveLine === false) { + editor.setHighlightActiveLine(false); + } + + if(data.hasOwnProperty('readOnly')) { + editor.setReadOnly(data.readOnly); + } + + if(data.hasOwnProperty('wordWrap')) { editor.getSession().setUseWrapMode(data.wordWrap); } - - if (data.border) { + + if (data.hasOwnProperty("useSoftTabs")) { + editor.setOption("useSoftTabs", data.useSoftTabs); + } + + if (data.hasOwnProperty("tabSize")) { + editor.setOption("tabSize", data.tabSize); + } + + if (data.hasOwnProperty("showInvisibles")) { + editor.setOption("showInvisibles", data.showInvisibles); + } + + if (data.hasOwnProperty('border')) { var classes = ['acenormal', 'aceflash', 'acealert']; $el.removeClass(classes.join(' ')); $el.addClass(data.border); } - - if (data.autoComplete) { + + if (data.hasOwnProperty('autoComplete')) { var value = data.autoComplete; editor.setOption('enableLiveAutocompletion', value === 'live'); editor.setOption('enableBasicAutocompletion', value !== 'disabled'); } - + if (data.hasOwnProperty('autoCompleters')) { var completers = data.autoCompleters; editor.completers = []; @@ -142,31 +230,79 @@ }); } } - - if (data.tabSize) { - editor.setOption('tabSize', data.tabSize); - } - - if (data.useSoftTabs === false) { - editor.setOption('useSoftTabs', false); - } else if (data.useSoftTabs === true) { - editor.setOption('useSoftTabs', true); - } - - if (data.showInvisibles === true) { - editor.setOption('showInvisibles', true); - } else if (data.showInvisibles === false) { - editor.setOption('showInvisibles', false); - } - + if (data.hasOwnProperty('autoCompleteList')) { $el.data('auto-complete-list', data.autoCompleteList); } - - if (data.codeCompletions) { - var callback = $el.data('autoCompleteCallback'); + + if (data.hasOwnProperty("setBehavioursEnabled") && data.setBehavioursEnabled === false) { + editor.setBehavioursEnabled(data.setBehavioursEnabled); + } + + if (data.hasOwnProperty("autoScrollEditorIntoView") && data.autoScrollEditorIntoView === true) { + editor.setOption("autoScrollEditorIntoView", true); + if (data.hasOwnProperty("maxLines")) { + editor.setOption("maxLines", data.maxLines); + } + if (data.hasOwnProperty("minLines")) { + editor.setOption("minLines", data.minLines); + } + } + + if (data.hasOwnProperty("codeCompletions")) { + var callback = $(el).data('autoCompleteCallback'); if(callback !== undefined) callback(null, data.codeCompletions); } + + + if (typeof $(el).data('aceEditor') == 'undefined') + $(el).data("aceEditor", editor); + + }; + + var shinyAceInputBinding = new Shiny.InputBinding(); + $.extend(shinyAceInputBinding, { + find: function(scope) { + return $(scope).find(".shiny-ace"); + }, + initialize: function(el) { + var scriptData = document.querySelector("script[data-for='" + el.id + "'][type='application/json']"); + if (scriptData) { + var data = JSON.parse(scriptData.textContent); + updateEditor(el, data); + } + }, + getValue: function(el) { + return($(el).data('aceEditor').getValue()); + }, + setValue: function(el, value) { + //TODO + }, + subscribe: function(el, callback) { + $(el).data('aceChangeCallback', function(e) { + callback(true); + }); + + $(el).data('aceEditor').getSession().addEventListener("change", + $(el).data('aceChangeCallback') + ); + }, + unsubscribe: function(el) { + $(el).data('aceEditor').getSession().removeEventListener("change", + $(el).data('aceChangeCallback')); + }, + getRatePolicy: function(el){ + return ({policy: 'debounce', delay: $(el).data('debounce') || 1000 }); + } + }); + + Shiny.inputBindings.register(shinyAceInputBinding); + + Shiny.addCustomMessageHandler('shinyAce', function(data) { + var id = data.id; + var $el = $('#' + id); + var el = document.getElementById( data.id ); + updateEditor(el, data); }); // Allow toggle of the search-replace box in Ace @@ -177,4 +313,4 @@ sb[isReplace ? "replaceInput" : "searchInput"].focus(); }); -})(); \ No newline at end of file +})(); diff --git a/man/jsQuote.Rd b/man/jsQuote.Rd deleted file mode 100644 index 71100ad..0000000 --- a/man/jsQuote.Rd +++ /dev/null @@ -1,17 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/js-quote.R -\name{jsQuote} -\alias{jsQuote} -\title{Escape a JS String} -\usage{ -jsQuote(text) -} -\arguments{ -\item{text}{The text to escape} -} -\description{ -Escape a String to be sent to JavaScript -} -\author{ -Jeff Allen \email{jeff@trestletech.com} -} From ad48ae4bf6dd9ecba0e5f6a71c5b733dfb6e4afb Mon Sep 17 00:00:00 2001 From: detule Date: Wed, 12 Jun 2019 12:58:54 -0400 Subject: [PATCH 2/4] tests: RM test-js-quote --- tests/testthat/test-dummy.R | 5 +++++ tests/testthat/test-js-quote.R | 28 ---------------------------- 2 files changed, 5 insertions(+), 28 deletions(-) create mode 100644 tests/testthat/test-dummy.R delete mode 100644 tests/testthat/test-js-quote.R diff --git a/tests/testthat/test-dummy.R b/tests/testthat/test-dummy.R new file mode 100644 index 0000000..c5f2de9 --- /dev/null +++ b/tests/testthat/test-dummy.R @@ -0,0 +1,5 @@ +context("dummy test") + +test_that("one plus one", { + expect_identical(1 + 1, 2) +}) diff --git a/tests/testthat/test-js-quote.R b/tests/testthat/test-js-quote.R deleted file mode 100644 index b81bcd0..0000000 --- a/tests/testthat/test-js-quote.R +++ /dev/null @@ -1,28 +0,0 @@ -context("test_js_quote") -test_that("plaintext works", { - string <- "text here" - expect_match(jsQuote(string), "^['\"]text here['\"]$") -}) - -test_that("newline works", { - string <- "text\nhere" - expect_match(jsQuote(string), "^['\"]text\\\\nhere['\"]$") -}) - -test_that("esc r works", { - string <- "text\rhere" - expect_match(jsQuote(string), "^['\"]text\\\\rhere['\"]$") -}) - -test_that("esc f works", { - string <- "text\fhere" - expect_match(jsQuote(string), "^['\"]text\\\\fhere['\"]$") -}) - -context("sanitize") -test_that("sanitization works", { - expect_equal(sanitizeId("test"), "test") - expect_equal(sanitizeId("test--2!"), "test2") - expect_equal(sanitizeId("!@#test"), "test") - expect_equal(sanitizeId("t!e%s#--t3"), "test3") -}) \ No newline at end of file From 7ab2d2c9db02aac6468812f5db149ed873dee883 Mon Sep 17 00:00:00 2001 From: detule Date: Mon, 17 Jun 2019 20:28:10 -0400 Subject: [PATCH 3/4] shinyAce.js: Fixup jQuery alias usage --- inst/www/shinyAce.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/inst/www/shinyAce.js b/inst/www/shinyAce.js index 95f3c2e..54fff30 100644 --- a/inst/www/shinyAce.js +++ b/inst/www/shinyAce.js @@ -192,8 +192,8 @@ if (data.hasOwnProperty('border')) { var classes = ['acenormal', 'aceflash', 'acealert']; - $el.removeClass(classes.join(' ')); - $el.addClass(data.border); + $(el).removeClass(classes.join(' ')); + $(el).addClass(data.border); } if (data.hasOwnProperty('autoComplete')) { @@ -232,7 +232,7 @@ } if (data.hasOwnProperty('autoCompleteList')) { - $el.data('auto-complete-list', data.autoCompleteList); + $(el).data('auto-complete-list', data.autoCompleteList); } if (data.hasOwnProperty("setBehavioursEnabled") && data.setBehavioursEnabled === false) { @@ -300,7 +300,6 @@ Shiny.addCustomMessageHandler('shinyAce', function(data) { var id = data.id; - var $el = $('#' + id); var el = document.getElementById( data.id ); updateEditor(el, data); }); From e6eb8e7c2cf5dd7e103548c6fc6dfdadfe998891 Mon Sep 17 00:00:00 2001 From: Oliver Gjoneski Date: Tue, 25 Jun 2019 13:54:19 +0000 Subject: [PATCH 4/4] autoCompleters: More careful treatment of autoCompleters == '' --- R/ace-editor.R | 11 +++++++---- R/update-ace-editor.R | 13 +++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/R/ace-editor.R b/R/ace-editor.R index 1e057de..8ab023b 100644 --- a/R/ace-editor.R +++ b/R/ace-editor.R @@ -45,10 +45,10 @@ #' By default, only local completer is used where all aforementioned code pieces #' will be considered as candidates. Use \code{autoCompleteList} for static #' completions and \code{\link{aceAutocomplete}} for dynamic R code completions. -#' @param autoCompleters List of completers to enable. If set to \code{NULL}, +#' @param autoCompleters Character vector of completers to enable. If set to \code{NULL}, #' all completers will be disabled. Select one or more of "snippet", "text", "static", -#' and "keyword" to control which completers to use. Default option is an empty character -#' vector which does not effect default completion options +#' "keyword", and "rlang" to control which completers to use. Default option is an +#' empty character vector which does not effect default completion options. #' @param autoCompleteList A named list that contains static code completions #' candidates. This can be especially useful for Non-Standard Evaluation (NSE) #' functions such as those in \code{dplyr} and \code{ggvis}. Each element in list @@ -131,7 +131,6 @@ aceEditor <- function( cursorId = cursorId, hotkeys = hotkeys, autoComplete = match.arg(autoComplete), - autoCompleters = I(autoCompleters), autoCompleteList = autoCompleteList, tabSize = tabSize, useSoftTabs = useSoftTabs, @@ -142,6 +141,10 @@ aceEditor <- function( minLines = minLines ) + if(is.null(autoCompleters)) + payloadLst$autoComplete <- "disabled" + if(sum(autoCompleters %in% c("snippet", "text", "static", "keyword", "rlang")) > 0) + payloadLst$autoCompleters <- I(autoCompleters) if(!missing(value)) payloadLst$value <- value if(!missing(mode)) payloadLst$mode <- mode if(!missing(theme)) payloadLst$theme <- theme diff --git a/R/update-ace-editor.R b/R/update-ace-editor.R index 41c1241..f7290a8 100644 --- a/R/update-ace-editor.R +++ b/R/update-ace-editor.R @@ -25,7 +25,7 @@ #' @param border Set the \code{border} 'normal', 'alert', or 'flash'. #' @param autoComplete Enable/Disable code completion. See \code{\link{aceEditor}} #' for details. -#' @param autoCompleters List of completers to enable. If set to \code{NULL}, +#' @param autoCompleters Character vector of completers to enable. If set to \code{NULL}, #' all completers will be disabled. #' @param autoCompleteList If set to \code{NULL}, existing static completions #' list will be unset. See \code{\link{aceEditor}} for details. @@ -81,14 +81,15 @@ updateAceEditor <- function( } if (!missing(autoComplete)) { - autoComplete <- match.arg(autoComplete) + if (!is.null(autoCompleters)) + autoComplete <- "disabled" + else + autoComplete <- match.arg(autoComplete) theList["autoComplete"] <- autoComplete } - if (!missing(autoCompleters)) { - if (!is.null(autoCompleters)) { - autoCompleters <- match.arg(autoCompleters, several.ok = TRUE) - } + if (!missing(autoCompleters) && !is.null(autoCompleters)) { + autoCompleters <- match.arg(autoCompleters, several.ok = TRUE) theList <- c(theList, list(autoCompleters = autoCompleters)) }