diff --git a/_extensions/webr/_extension.yml b/_extensions/webr/_extension.yml index 51da7f46..6139b24c 100644 --- a/_extensions/webr/_extension.yml +++ b/_extensions/webr/_extension.yml @@ -1,7 +1,7 @@ name: webr title: Embedded webr code cells author: James Joseph Balamuta -version: 0.4.0-dev.3 +version: 0.4.0-dev.4 quarto-required: ">=1.2.198" contributes: filters: diff --git a/_extensions/webr/qwebr-cell-elements.js b/_extensions/webr/qwebr-cell-elements.js new file mode 100644 index 00000000..df41e26e --- /dev/null +++ b/_extensions/webr/qwebr-cell-elements.js @@ -0,0 +1,115 @@ +// Function that dispatches the creation request +globalThis.qwebrCreateHTMLElement = function (insertElement, + qwebrCounter, + evalType = EvalTypes.Interactive, + options = {}) { + + // Figure out the routine to use to insert the element. + let qwebrElement; + switch ( evalType ) { + case EvalTypes.Interactive: + qwebrElement = qwebrCreateInteractiveElement(qwebrCounter); + case EvalTypes.Output: + qwebrElement = qwebrCreateNonInteractiveOutputElement(qwebrCounter); + case EvalTypes.Setup: + qwebrElement = qwebrCreateNonInteractiveSetupElement(qwebrCounter); + default: + qwebrElement = document.createElement('div'); + qwebrElement.textContent = 'Error creating element'; + } + + // Insert the dynamically generated object at the document location. + insertElement.appendChild(qwebrElement); +}; + +// Function that setups the interactive element creation +globalThis.qwebrCreateInteractiveElement = function (qwebrCounter) { + + // Create main div element + var mainDiv = document.createElement('div'); + mainDiv.id = 'qwebr-interactive-area-' + qwebrCounter; + mainDiv.className = 'qwebr-interactive-area'; + + // Create button element + var button = document.createElement('button'); + button.className = 'btn btn-default qwebr-button-run'; + button.disabled = true; + button.type = 'button'; + button.id = 'qwebr-button-run-' + qwebrCounter; + button.textContent = '🟡 Loading webR...'; + + // Create console area div + var consoleAreaDiv = document.createElement('div'); + consoleAreaDiv.id = 'qwebr-console-area-' + qwebrCounter; + consoleAreaDiv.className = 'qwebr-console-area'; + + // Create editor div + var editorDiv = document.createElement('div'); + editorDiv.id = 'qwebr-editor-' + qwebrCounter; + editorDiv.className = 'qwebr-editor'; + + // Create output code area div + var outputCodeAreaDiv = document.createElement('div'); + outputCodeAreaDiv.id = 'qwebr-output-code-area-' + qwebrCounter; + outputCodeAreaDiv.className = 'qwebr-output-code-area'; + outputCodeAreaDiv.setAttribute('aria-live', 'assertive'); + + // Create pre element inside output code area + var preElement = document.createElement('pre'); + preElement.style.visibility = 'hidden'; + outputCodeAreaDiv.appendChild(preElement); + + // Create output graph area div + var outputGraphAreaDiv = document.createElement('div'); + outputGraphAreaDiv.id = 'qwebr-output-graph-area-' + qwebrCounter; + outputGraphAreaDiv.className = 'qwebr-output-graph-area'; + + // Append all elements to the main div + mainDiv.appendChild(button); + consoleAreaDiv.appendChild(editorDiv); + consoleAreaDiv.appendChild(outputCodeAreaDiv); + mainDiv.appendChild(consoleAreaDiv); + mainDiv.appendChild(outputGraphAreaDiv); + + return mainDiv; +} + +// Function that adds output structure for non-interactive output +globalThis.qwebrCreateNonInteractiveOutputElement = function(qwebrCounter) { + // Create main div element + var mainDiv = document.createElement('div'); + mainDiv.id = 'qwebr-noninteractive-area-' + qwebrCounter; + mainDiv.className = 'qwebr-noninteractive-area'; + + // Create output code area div + var outputCodeAreaDiv = document.createElement('div'); + outputCodeAreaDiv.id = 'qwebr-output-code-area-' + qwebrCounter; + outputCodeAreaDiv.className = 'qwebr-output-code-area'; + outputCodeAreaDiv.setAttribute('aria-live', 'assertive'); + + // Create pre element inside output code area + var preElement = document.createElement('pre'); + preElement.style.visibility = 'hidden'; + outputCodeAreaDiv.appendChild(preElement); + + // Create output graph area div + var outputGraphAreaDiv = document.createElement('div'); + outputGraphAreaDiv.id = 'qwebr-output-graph-area-' + qwebrCounter; + outputGraphAreaDiv.className = 'qwebr-output-graph-area'; + + // Append all elements to the main div + mainDiv.appendChild(outputCodeAreaDiv); + mainDiv.appendChild(outputGraphAreaDiv); + + return mainDiv; +}; + +// Function that adds a stub in the page to indicate a setup cell was used. +globalThis.qwebrCreateNonInteractiveSetupElement = function(qwebrCounter) { + // Create main div element + var mainDiv = document.createElement('div'); + mainDiv.id = 'qwebr-noninteractive-setup-area-' + qwebrCounter; + mainDiv.className = 'qwebr-noninteractive-setup-area'; + + return mainDiv; +} \ No newline at end of file diff --git a/_extensions/webr/qwebr-compute-engine.js b/_extensions/webr/qwebr-compute-engine.js new file mode 100644 index 00000000..173c2e6f --- /dev/null +++ b/_extensions/webr/qwebr-compute-engine.js @@ -0,0 +1,210 @@ +// Supported Evaluation Types for Context +globalThis.EvalTypes = Object.freeze({ + Interactive: 'interactive', + Setup: 'setup', + Output: 'output', +}); + +// Function to verify a given JavaScript Object is empty +globalThis.qwebrIsObjectEmpty = function (arr) { + return Object.keys(arr).length === 0; +} + +// Function to parse the pager results +globalThis.qwebrParseTypePager = async function (msg) { + + // Split out the event data + const { path, title, deleteFile } = msg.data; + + // Process the pager data by reading the information from disk + const paged_data = await webR.FS.readFile(path).then((data) => { + // Obtain the file content + let content = new TextDecoder().decode(data); + + // Remove excessive backspace characters until none remain + while(content.match(/.[\b]/)){ + content = content.replace(/.[\b]/g, ''); + } + + // Returned cleaned data + return content; + }); + + // Unlink file if needed + if (deleteFile) { + await webR.FS.unlink(path); + } + + // Return extracted data with spaces + return paged_data; +} + +// Function to run the code using webR and parse the output +globalThis.qwebrComputeEngine = async function( + codeToRun, + elements, + options) { + + // Call into the R compute engine that persists within the document scope. + // To be prepared for all scenarios, the following happens: + // 1. We setup a canvas device to write to by making a namespace call into the {webr} package + // 2. We use values inside of the options array to set the figure size. + // 3. We capture the output stream information (STDOUT and STERR) + // 4. While parsing the results, we disable image creation. + + // Create a canvas variable for graphics + let canvas = undefined; + + // Create a pager variable for help/file contents + let pager = []; + + // ---- + + // Initialize webR + await webR.init(); + + // Setup a webR canvas by making a namespace call into the {webr} package + await webR.evalRVoid(`webr::canvas(width=${options["fig-width"]}, height=${options["fig-height"]})`); + + const result = await webRCodeShelter.captureR(codeToRun, { + withAutoprint: true, + captureStreams: true, + captureConditions: false//, + // env: webR.objs.emptyEnv, // maintain a global environment for webR v0.2.0 + }); + + // ----- + + // Start attempting to parse the result data + try { + + // Stop creating images + await webR.evalRVoid("dev.off()"); + + // Merge output streams of STDOUT and STDErr (messages and errors are combined.) + const out = result.output + .filter(evt => evt.type === "stdout" || evt.type === "stderr") + .map((evt, index) => { + const className = `qwebr-output-code-${evt.type}`; + return `${qwebrEscapeHTMLCharacters(evt.data)}`; + }) + .join("\n"); + + + // Clean the state + // We're now able to process both graphics and pager events. + // As a result, we cannot maintain a true 1-to-1 output order + // without individually feeding each line + const msgs = await webR.flush(); + + // Output each image event stored + msgs.forEach((msg) => { + // Determine if old canvas can be used or a new canvas is required. + if (msg.type === 'canvas'){ + // Add image to the current canvas + if (msg.data.event === 'canvasImage') { + canvas.getContext('2d').drawImage(msg.data.image, 0, 0); + } else if (msg.data.event === 'canvasNewPage') { + // Generate a new canvas element + canvas = document.createElement("canvas"); + canvas.setAttribute("width", 2 * options["fig-width"]); + canvas.setAttribute("height", 2 * options["fig-height"]); + canvas.style.width = "700px"; + canvas.style.display = "block"; + canvas.style.margin = "auto"; + } + } + }); + + // Use `map` to process the filtered "pager" events asynchronously + const pager = await Promise.all( + msgs.filter(msg => msg.type === 'pager').map( + async (msg) => { + return await qwebrParseTypePager(msg); + } + ) + ); + + // Nullify the output area of content + elements.outputCodeDiv.innerHTML = ""; + elements.outputGraphDiv.innerHTML = ""; + + // Design an output object for messages + const pre = document.createElement("pre"); + if (/\S/.test(out)) { + // Display results as HTML elements to retain output styling + const div = document.createElement("div"); + div.innerHTML = out; + pre.appendChild(div); + } else { + // If nothing is present, hide the element. + pre.style.visibility = "hidden"; + } + + elements.outputCodeDiv.appendChild(pre); + + // Place the graphics on the canvas + if (canvas) { + elements.outputGraphDiv.appendChild(canvas); + } + + // Display the pager data + if (pager) { + // Use the `pre` element to preserve whitespace. + pager.forEach((paged_data, index) => { + let pre_pager = document.createElement("pre"); + pre_pager.innerText = paged_data; + pre_pager.classList.add("qwebr-output-code-pager"); + pre_pager.setAttribute("id", `qwebr-output-code-pager-editor-${elements.id}-result-${index + 1}`); + elements.outputCodeDiv.appendChild(pre_pager); + }); + } + } finally { + // Clean up the remaining code + webRCodeShelter.purge(); + } +} + +// Function to execute the code (accepts code as an argument) +globalThis.qwebrExecuteCode = async function ( + codeToRun, + id, + evalType = EvalTypes.Interactive, + options = {}) { + + // If options are not passed, we fall back on the bare minimum to handle the computation + if (qwebrIsObjectEmpty(options)) { + options = { "fig-width": 504, "fig-height": 360 }; + } + + // Next, we access the compute areas values + const elements = { + runButton: document.getElementById(`qwebr-button-run-${id}`), + outputCodeDiv: document.getElementById(`qwebr-output-code-area-${id}`), + outputGraphDiv: document.getElementById(`qwebr-output-graph-area-${id}`), + id: id, + } + + // Disallowing execution of other code cells + document.querySelectorAll(".qwebr-button-run").forEach((btn) => { + btn.disabled = true; + }); + + if (evalType == EvalTypes.Interactive) { + // Emphasize the active code cell + elements.runButton.innerHTML = ' Run Code'; + } + + // Evaluate the code and parse the output into the document + await qwebrComputeEngine(codeToRun, elements, options); + + // Switch to allowing execution of code + document.querySelectorAll(".qwebr-button-run").forEach((btn) => { + btn.disabled = false; + }); + + if (evalType == EvalTypes.Interactive) { + // Revert to the initial code cell state + elements.runButton.innerHTML = ' Run Code'; + } +} diff --git a/_extensions/webr/qwebr-init.js b/_extensions/webr/qwebr-init.js new file mode 100644 index 00000000..533fb468 --- /dev/null +++ b/_extensions/webr/qwebr-init.js @@ -0,0 +1,193 @@ +// Start a timer +const initializeWebRTimerStart = performance.now(); + +// Determine if we need to install R packages +var installRPackagesList = [{{INSTALLRPACKAGESLIST}}]; +// Check to see if we have an empty array, if we do set to skip the installation. +var setupRPackages = !(installRPackagesList.indexOf("") !== -1); +var autoloadRPackages = {{AUTOLOADRPACKAGES}}; + +// Display a startup message? +var showStartupMessage = {{SHOWSTARTUPMESSAGE}}; +var showHeaderMessage = {{SHOWHEADERMESSAGE}}; +if (showStartupMessage) { + + // Get references to header elements + const headerHTML = document.getElementById("title-block-header"); + const headerRevealJS = document.getElementById("title-slide"); + + // Create the outermost div element for metadata + const quartoTitleMeta = document.createElement("div"); + quartoTitleMeta.classList.add("quarto-title-meta"); + + // Create the first inner div element + const firstInnerDiv = document.createElement("div"); + firstInnerDiv.setAttribute("id", "qwebr-status-message-area"); + + // Create the second inner div element for "WebR Status" heading and contents + const secondInnerDiv = document.createElement("div"); + secondInnerDiv.setAttribute("id", "qwebr-status-message-title"); + secondInnerDiv.classList.add("quarto-title-meta-heading"); + secondInnerDiv.innerText = "WebR Status"; + + // Create another inner div for contents + const secondInnerDivContents = document.createElement("div"); + secondInnerDivContents.setAttribute("id", "qwebr-status-message-body"); + secondInnerDivContents.classList.add("quarto-title-meta-contents"); + + // Describe the WebR state + var startupMessageWebR = document.createElement("p"); + startupMessageWebR.innerText = "🟡 Loading..."; + startupMessageWebR.setAttribute("id", "qwebr-status-message-text"); + // Add `aria-live` to auto-announce the startup status to screen readers + startupMessageWebR.setAttribute("aria-live", "assertive"); + + // Append the startup message to the contents + secondInnerDivContents.appendChild(startupMessageWebR); + + // Add a status indicator for COOP and COEP Headers if needed + if (showHeaderMessage) { + const crossOriginMessage = document.createElement("p"); + crossOriginMessage.innerText = `${crossOriginIsolated ? '🟢' : '🟡'} COOP & COEP Headers`; + crossOriginMessage.setAttribute("id", "qwebr-coop-coep-header"); + secondInnerDivContents.appendChild(crossOriginMessage); + } + + // Combine the inner divs and contents + firstInnerDiv.appendChild(secondInnerDiv); + firstInnerDiv.appendChild(secondInnerDivContents); + quartoTitleMeta.appendChild(firstInnerDiv); + + // Determine where to insert the quartoTitleMeta element + if (headerHTML) { + // Append to the existing "title-block-header" element + headerHTML.appendChild(quartoTitleMeta); + } else if (headerRevealJS) { + // If using RevealJS, add to the "title-slide" div + headerRevealJS.appendChild(firstInnerDiv); + } else { + // If neither headerHTML nor headerRevealJS is found, insert after "webr-monaco-editor-init" script + const monacoScript = document.getElementById("qwebr-monaco-editor-init"); + const header = document.createElement("header"); + header.setAttribute("id", "title-block-header"); + header.appendChild(quartoTitleMeta); + monacoScript.after(header); + } +} + +// Retrieve the webr.mjs +import { WebR, ChannelType } from "{{BASEURL}}webr.mjs"; + +// Populate WebR options with defaults or new values based on +// webr meta +globalThis.webR = new WebR({ + "baseURL": "{{BASEURL}}", + "serviceWorkerUrl": "{{SERVICEWORKERURL}}", + "homedir": "{{HOMEDIR}}", + "channelType": {{CHANNELTYPE}} +}); + +// Initialization WebR +await webR.init(); + +// Setup a shelter +globalThis.webRCodeShelter = await new webR.Shelter(); + +// Setup a pager to allow processing help documentation +await webR.evalRVoid('webr::pager_install()'); + +// Function to set the button text +function qwebrSetInteractiveButtonState(buttonText, enableCodeButton = true) { + document.querySelectorAll(".qwebr-button-run").forEach((btn) => { + btn.innerHTML = buttonText; + btn.disabled = !enableCodeButton; + }); +} + +// Function to update the status message +function qwebrUpdateStatusHeader(message) { + startupMessageWebR.innerHTML = ` + + ${message}`; +} + +// Function to install a single package +async function qwebrInstallRPackage(packageName) { + await globalThis.webR.installPackages([packageName]); +} + +// Function to load a single package +async function qwebrLoadRPackage(packageName) { + await globalThis.webR.evalRVoid(`library(${packageName});`); +} + +// Generic function to process R packages +async function qwebrProcessRPackagesWithStatus(packages, processType, displayStatusMessageUpdate = true) { + // Switch between contexts + const messagePrefix = processType === 'install' ? 'Installing' : 'Loading'; + + // Modify button state + qwebrSetInteractiveButtonState(`🟡 ${messagePrefix} package ...`, false); + + // Iterate over packages + for (let i = 0; i < packages.length; i++) { + const activePackage = packages[i]; + const formattedMessage = `${messagePrefix} package ${i + 1} out of ${packages.length}: ${activePackage}`; + + // Display the update + if (displayStatusMessageUpdate) { + qwebrUpdateStatusHeader(formattedMessage); + } + + // Run package installation + if (processType === 'install') { + await qwebrInstallRPackage(activePackage); + } else { + await qwebrLoadRPackage(activePackage); + } + } + + // Clean slate + if (processType === 'load') { + await globalThis.webR.flush(); + } +} + + +// Check to see if any packages need to be installed +if (setupRPackages) { + // Obtain only a unique list of packages + const uniqueRPackageList = Array.from(new Set(installRPackagesList)); + + // Install R packages one at a time (either silently or with a status update) + await qwebrProcessRPackagesWithStatus(uniqueRPackageList, 'install', showStartupMessage); + + if(autoloadRPackages) { + // Load R packages one at a time (either silently or with a status update) + await qwebrProcessRPackagesWithStatus(uniqueRPackageList, 'load', showStartupMessage); + } +} + +// Stop timer +const initializeWebRTimerEnd = performance.now(); + +// Release document status as ready +if (showStartupMessage) { + startupMessageWebR.innerText = "🟢 Ready!" +} + +qwebrSetInteractiveButtonState( + ` Run Code`, + true +); + +// Global version of the Escape HTML function that converts HTML +// characters to their HTML entities. +globalThis.qwebrEscapeHTMLCharacters = function(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; \ No newline at end of file diff --git a/_extensions/webr/qwebr-monaco-editor-element.js b/_extensions/webr/qwebr-monaco-editor-element.js new file mode 100644 index 00000000..21c4f124 --- /dev/null +++ b/_extensions/webr/qwebr-monaco-editor-element.js @@ -0,0 +1,132 @@ +// Global dictionary to store Monaco Editor instances +const qwebrEditorInstances = {}; + +// Function that builds and registers a Monaco Editor instance +globalThis.qwebrCreateMonacoEditorInstance = function ( + initialCode, + qwebrCounter) { + + // Retrieve the previously created document elements + let runButton = document.getElementById(`qwebr-button-run-${qwebrCounter}`); + let editorDiv = document.getElementById(`qwebr-editor-${qwebrCounter}`); + + // Load the Monaco Editor and create an instance + let editor; + require(['vs/editor/editor.main'], function () { + editor = monaco.editor.create(editorDiv, { + value: initialCode, + language: 'r', + theme: 'vs-light', + automaticLayout: true, // Works wonderfully with RevealJS + scrollBeyondLastLine: false, + minimap: { + enabled: false + }, + fontSize: '17.5pt', // Bootstrap is 1 rem + renderLineHighlight: "none", // Disable current line highlighting + hideCursorInOverviewRuler: true // Remove cursor indictor in right hand side scroll bar + }); + + // Store the official counter ID to be used in keyboard shortcuts + editor.__qwebrCounter = qwebrCounter; + + // Store the official div container ID + editor.__qwebrEditorId = `qwebr-editor-${qwebrCounter}`; + + // Store the initial code value + editor.__qwebrinitialCode = initialCode; + + // Dynamically modify the height of the editor window if new lines are added. + let ignoreEvent = false; + const updateHeight = () => { + const contentHeight = editor.getContentHeight(); + // We're avoiding a width change + //editorDiv.style.width = `${width}px`; + editorDiv.style.height = `${contentHeight}px`; + try { + ignoreEvent = true; + + // The key to resizing is this call + editor.layout(); + } finally { + ignoreEvent = false; + } + }; + + // Helper function to check if selected text is empty + function isEmptyCodeText(selectedCodeText) { + return (selectedCodeText === null || selectedCodeText === undefined || selectedCodeText === ""); + } + + // Registry of keyboard shortcuts that should be re-added to each editor window + // when focus changes. + const addWebRKeyboardShortCutCommands = () => { + // Add a keydown event listener for Shift+Enter to run all code in cell + editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { + + // Retrieve all text inside the editor + qwebrExecuteCode(editor.getValue(), editor.__qwebrCounter); + }); + + // Add a keydown event listener for CMD/Ctrl+Enter to run selected code + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { + + // Get the selected text from the editor + const selectedText = editor.getModel().getValueInRange(editor.getSelection()); + // Check if no code is selected + if (isEmptyCodeText(selectedText)) { + // Obtain the current cursor position + let currentPosition = editor.getPosition(); + // Retrieve the current line content + let currentLine = editor.getModel().getLineContent(currentPosition.lineNumber); + + // Propose a new position to move the cursor to + let newPosition = new monaco.Position(currentPosition.lineNumber + 1, 1); + + // Check if the new position is beyond the last line of the editor + if (newPosition.lineNumber > editor.getModel().getLineCount()) { + // Add a new line at the end of the editor + editor.executeEdits("addNewLine", [{ + range: new monaco.Range(newPosition.lineNumber, 1, newPosition.lineNumber, 1), + text: "\n", + forceMoveMarkers: true, + }]); + } + + // Run the entire line of code. + qwebrExecuteCode(currentLine, editor.__qwebrCounter, + EvalTypes.Interactive); + + // Move cursor to new position + editor.setPosition(newPosition); + } else { + // Code to run when Ctrl+Enter is pressed with selected code + qwebrExecuteCode(selectedText, editor.__qwebrCounter, EvalTypes.Interactive); + } + }); + } + + // Register an on focus event handler for when a code cell is selected to update + // what keyboard shortcut commands should work. + // This is a workaround to fix a regression that happened with multiple + // editor windows since Monaco 0.32.0 + // https://github.com/microsoft/monaco-editor/issues/2947 + editor.onDidFocusEditorText(addWebRKeyboardShortCutCommands); + + // Register an on change event for when new code is added to the editor window + editor.onDidContentSizeChange(updateHeight); + + // Manually re-update height to account for the content we inserted into the call + updateHeight(); + + // Store the editor instance in the global dictionary + qwebrEditorInstances[editor.__qwebrCounter] = editor; + + }); + + // Add a click event listener to the run button + runButton.onclick = function () { + qwebrExecuteCode(editor.getValue(), editor.__qwebrCounter, EvalTypes.Interactive); + }; + +} \ No newline at end of file diff --git a/_extensions/webr/qwebr-styling.css b/_extensions/webr/qwebr-styling.css new file mode 100644 index 00000000..f9d7ea72 --- /dev/null +++ b/_extensions/webr/qwebr-styling.css @@ -0,0 +1,104 @@ +.monaco-editor pre { + background-color: unset !important; +} + +.qwebr-icon-status-spinner { + color: #7894c4; +} + +.qwebr-icon-run-code { + color: #0d9c29 +} + +.qwebr-output-code-stdout { + color: #111; +} + +.qwebr-output-code-stderr { + color: #db4133; +} + +.qwebr-editor { + border: 1px solid #EEEEEE; +} + +.qwebr-button-run { + background-color: #EEEEEE; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; /* Extra styling for consistency */ + display: inline-block; + font-weight: 400; + line-height: 1.5; + color: #000; + text-align: center; + text-decoration: none; + -webkit-text-decoration: none; + -moz-text-decoration: none; + -ms-text-decoration: none; + -o-text-decoration: none; + /* vertical-align: middle; */ /* Prevents a space from appearing between the code cell and button */ + -webkit-user-select: none; + border-color: #dee2e6; + border: 1px solid rgba(0,0,0,0); + padding: 0.375rem 0.75rem; + font-size: 1rem; + border-top-right-radius: 0.25rem; + border-top-left-radius: 0.25rem; + transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out; +} + +.qwebr-button-run:hover { + color: #000; + background-color: #e3e6ea; + border-color: #e1e5e9; +} + +.qwebr-button-run:disabled,.qwebr-button-run.disabled,fieldset:disabled .qwebr-button-run { + pointer-events: none; + opacity: .65 +} + +/* Custom styling for RevealJS Presentations*/ + +/* Reset the style of the interactive area */ +.reveal div.qwebr-interactive-area { + display: block; + box-shadow: none; + max-width: 100%; + max-height: 100%; + margin: 0; + padding: 0; +} + +/* Provide space to entries */ +.reveal div.qwebr-output-code-area pre div { + margin: 1px 2px 1px 10px; +} + +/* Collapse the inside code tags to avoid extra space between line outputs */ +.reveal pre div code.qwebr-output-code-stdout, .reveal pre div code.qwebr-output-code-stderr { + padding: 0; + display: contents; +} + +.reveal pre div code.qwebr-output-code-stdout { + color: #111; +} + +.reveal pre div code.qwebr-output-code-stderr { + color: #db4133; +} + + +/* Create a border around console and output (does not effect graphs) */ +.reveal div.qwebr-console-area { + border: 1px solid #EEEEEE; + box-shadow: 2px 2px 10px #EEEEEE; +} + +/* Cap output height and allow text to scroll */ +/* TODO: Is there a better way to fit contents/max it parallel to the monaco editor size? */ +.reveal div.qwebr-output-code-area pre { + max-height: 400px; + overflow: scroll; +} diff --git a/_extensions/webr/webr-context-interactive.html b/_extensions/webr/webr-context-interactive.html index 55f91b3a..63d8d2bf 100644 --- a/_extensions/webr/webr-context-interactive.html +++ b/_extensions/webr/webr-context-interactive.html @@ -1,278 +1,19 @@ -
- -
-
-
-

-    
-
-
-
-
+
\ No newline at end of file +// Retrieve the insertion point +const currentDocumentLocation = document.getElementById("qwebr-insertion-location-{{WEBRCOUNTER}}"); + +// Initalize an interactive element +const initializedElement = qwebrCreateInteractiveElement( + {{WEBRCOUNTER}} + ); + +// Add the interactive element into the document scope +currentDocumentLocation.appendChild(initializedElement); + +// Initialize a Monaco Editor Instance +qwebrCreateMonacoEditorInstance( + `{{WEBRCODE}}`, + {{WEBRCOUNTER}}); + + \ No newline at end of file diff --git a/_extensions/webr/webr-context-output.html b/_extensions/webr/webr-context-output.html index b086cf20..60eada55 100644 --- a/_extensions/webr/webr-context-output.html +++ b/_extensions/webr/webr-context-output.html @@ -1,151 +1,17 @@ -
-
-

-  
-
-
-
+
\ No newline at end of file +// Run the code in a non-interactive state +await qwebrExecuteCode(`{{WEBRCODE}}`, {{WEBRCOUNTER}}, EvalTypes.Output) + + diff --git a/_extensions/webr/webr-context-setup.html b/_extensions/webr/webr-context-setup.html index 918337bd..b0d52e01 100644 --- a/_extensions/webr/webr-context-setup.html +++ b/_extensions/webr/webr-context-setup.html @@ -1,4 +1,16 @@ +
\ No newline at end of file + + diff --git a/_extensions/webr/webr-init.html b/_extensions/webr/webr-init.html deleted file mode 100644 index 1defaec0..00000000 --- a/_extensions/webr/webr-init.html +++ /dev/null @@ -1,305 +0,0 @@ - - - - - \ No newline at end of file diff --git a/_extensions/webr/webr.lua b/_extensions/webr/webr.lua index 0d7cddb6..25c63d7b 100644 --- a/_extensions/webr/webr.lua +++ b/_extensions/webr/webr.lua @@ -216,7 +216,7 @@ end -- Obtain the initialization template file at webr-init.html function initializationTemplateFile() - return readTemplateFile("webr-init.html") + return readTemplateFile("qwebr-init.js") end -- Cache a copy of each public-facing templates to avoid multiple read/writes. @@ -320,6 +320,50 @@ function initializationWebR() return initializedWebRConfiguration end +function generateHTMLElement(tag) + -- Store a map containing opening and closing tabs + local tagMappings = { + js = { opening = "" }, + css = { opening = "" } + } + + -- Find the tag + local tagMapping = tagMappings[tag] + + -- If present, extract tag and return + if tagMapping then + return tagMapping.opening, tagMapping.closing + else + quarto.log.error("Invalid tag specified") + end +end + +-- Custom functions to include values into Quarto +-- https://quarto.org/docs/extensions/lua-api.html#includes + +local function includeTextInHTMLTag(location, text, tag) + + -- Obtain the HTML element opening and closing tag + local openingTag, closingTag = generateHTMLElement(tag) + + -- Insert the file into the document using the correct opening and closing tags + quarto.doc.include_text(location, openingTag .. text .. closingTag) + +end + +local function includeFileInHTMLTag(location, file, tag) + + -- Obtain the HTML element opening and closing tag + local openingTag, closingTag = generateHTMLElement(tag) + + -- Retrieve the file contents + local fileContents = readTemplateFile(file) + + -- Insert the file into the document using the correct opening and closing tags + quarto.doc.include_text(location, openingTag .. fileContents .. closingTag) + +end + -- Setup WebR's pre-requisites once per document. function ensureWebRSetup() @@ -332,14 +376,32 @@ function ensureWebRSetup() hasDoneWebRSetup = true local initializedConfigurationWebR = initializationWebR() - - -- Insert the web initialization - -- https://quarto.org/docs/extensions/lua-api.html#includes - quarto.doc.include_text("in-header", initializedConfigurationWebR) + + -- Embed Support Files to Avoid Resource Registration Issues + -- Note: We're not able to use embed-resources due to the web assembly binary and the potential for additional service worker files. + quarto.doc.include_text("in-header", [[ + + + ]]) + + -- Insert the extension styling for defined elements + includeFileInHTMLTag("in-header", "qwebr-styling.css", "css") + + -- Insert the customized startup procedure + includeTextInHTMLTag("in-header", initializedConfigurationWebR, "js") + + -- Insert the extension computational engine that calls webR + includeFileInHTMLTag("in-header", "qwebr-compute-engine.js", "js") + + -- Insert the extension element creation scripts + includeFileInHTMLTag("in-header", "qwebr-cell-elements.js", "js") -- Insert the monaco editor initialization quarto.doc.include_file("before-body", "monaco-editor-init.html") + -- Insert the extension styling for defined elements + includeFileInHTMLTag("before-body", "qwebr-monaco-editor-element.js", "js") + -- If the ChannelType requires service workers, register and copy them into the -- output directory. if hasServiceWorkerFiles then diff --git a/docs/qwebr-release-notes.qmd b/docs/qwebr-release-notes.qmd index 811c0938..a4aa8fde 100644 --- a/docs/qwebr-release-notes.qmd +++ b/docs/qwebr-release-notes.qmd @@ -10,8 +10,13 @@ format: # 0.4.0: ???? (??-??-????) [DEV] +## Breaking changes + +- Internal JavaScript functions used by the extension have been significantly changed. + ## Features +- Optimized the underlying code handling insertion and execution of R code. ([#118](https://github.com/coatless/quarto-webr/pulls/118)) ## Changes diff --git a/tests/qwebr-test-multiple-cells.qmd b/tests/qwebr-test-multiple-cells.qmd new file mode 100644 index 00000000..a9aea62c --- /dev/null +++ b/tests/qwebr-test-multiple-cells.qmd @@ -0,0 +1,35 @@ +--- +title: "Test: Multiple webR cells" +format: html +engine: knitr +filters: + - webr +--- + +Ensure each webR cell outputs in its own area. + +## Interactive + +```{webr-r} +1 + 1 +``` + +Graph in separate code cell + +```{webr-r} +plot(pressure) +``` + +## Non-interactive + +```{webr-r} +#| context: output +1 + 1 +``` + +Graph in separate code cell + +```{webr-r} +#| context: output +plot(pressure) +``` \ No newline at end of file diff --git a/tests/qwebr-test-output-graph.qmd b/tests/qwebr-test-output-graph.qmd new file mode 100644 index 00000000..81025e2e --- /dev/null +++ b/tests/qwebr-test-output-graph.qmd @@ -0,0 +1,21 @@ +--- +title: "Test: Graphics Output" +format: html +engine: knitr +filters: + - webr +--- + +This webpage tests the interactive and output contexts for showing a graph + +## Interactive +```{webr-r} +#| context: interactive +plot(pressure) +``` + +## Non-interactive +```{webr-r} +#| context: output +plot(pressure) +``` \ No newline at end of file