From 3d4f9fb9d887590cb1f0ad487bcaf5afde6daad5 Mon Sep 17 00:00:00 2001 From: Pawel Rucki <12943682+pawelru@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:59:32 +0100 Subject: [PATCH 01/15] use setup-r-dependencies action --- .github/workflows/check.yml | 34 +- .github/workflows/docs.yml | 42 +-- book/_extensions/coatless/webr/_extension.yml | 4 +- .../webr/qwebr-cell-initialization.js | 4 + .../coatless/webr/qwebr-compute-engine.js | 214 +++++++++--- .../qwebr-document-engine-initialization.js | 10 +- .../coatless/webr/qwebr-document-history.js | 110 +++++++ .../coatless/webr/qwebr-document-status.js | 306 +++++++++++++++++- .../webr/qwebr-monaco-editor-element.js | 61 +++- .../coatless/webr/qwebr-styling.css | 97 +++++- .../coatless/webr/webr-serviceworker.js | 1 - book/_extensions/coatless/webr/webr-worker.js | 1 - book/_extensions/coatless/webr/webr.lua | 301 +++++++++++------ .../quarto-ext/fontawesome/_extension.yml | 2 +- .../quarto-ext/fontawesome/assets/css/all.css | 117 +++++-- .../fontawesome/assets/css/all.min.css | 9 + .../FontAwesome6Brands-Regular-400.ttf | Bin 180104 -> 0 bytes .../FontAwesome6Brands-Regular-400.woff2 | Bin 92140 -> 0 bytes .../webfonts/FontAwesome6Free-Regular-400.ttf | Bin 76072 -> 0 bytes .../FontAwesome6Free-Regular-400.woff2 | Bin 25124 -> 0 bytes .../webfonts/FontAwesome6Free-Solid-900.ttf | Bin 384736 -> 0 bytes .../webfonts/FontAwesome6Free-Solid-900.woff2 | Bin 133940 -> 0 bytes .../assets/webfonts/fa-brands-400.ttf | Bin 181852 -> 209128 bytes .../assets/webfonts/fa-brands-400.woff2 | Bin 105536 -> 117852 bytes .../assets/webfonts/fa-regular-400.ttf | Bin 60520 -> 67860 bytes .../assets/webfonts/fa-regular-400.woff2 | Bin 23940 -> 25392 bytes .../assets/webfonts/fa-solid-900.ttf | Bin 388460 -> 420332 bytes .../assets/webfonts/fa-solid-900.woff2 | Bin 154228 -> 156400 bytes .../assets/webfonts/fa-v4compatibility.ttf | Bin 10556 -> 10832 bytes .../assets/webfonts/fa-v4compatibility.woff2 | Bin 4960 -> 4792 bytes .../quarto-ext/fontawesome/fontawesome.lua | 4 +- .../quarto-ext/shinylive/shinylive.lua | 7 + package/staged_dependencies.yaml | 70 ---- 33 files changed, 1093 insertions(+), 301 deletions(-) create mode 100644 book/_extensions/coatless/webr/qwebr-document-history.js delete mode 100644 book/_extensions/coatless/webr/webr-serviceworker.js delete mode 100644 book/_extensions/coatless/webr/webr-worker.js create mode 100644 book/_extensions/quarto-ext/fontawesome/assets/css/all.min.css delete mode 100644 book/_extensions/quarto-ext/fontawesome/assets/webfonts/FontAwesome6Brands-Regular-400.ttf delete mode 100644 book/_extensions/quarto-ext/fontawesome/assets/webfonts/FontAwesome6Brands-Regular-400.woff2 delete mode 100644 book/_extensions/quarto-ext/fontawesome/assets/webfonts/FontAwesome6Free-Regular-400.ttf delete mode 100644 book/_extensions/quarto-ext/fontawesome/assets/webfonts/FontAwesome6Free-Regular-400.woff2 delete mode 100644 book/_extensions/quarto-ext/fontawesome/assets/webfonts/FontAwesome6Free-Solid-900.ttf delete mode 100644 book/_extensions/quarto-ext/fontawesome/assets/webfonts/FontAwesome6Free-Solid-900.woff2 delete mode 100644 package/staged_dependencies.yaml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f85af9bb73..06d047cc39 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,34 +20,58 @@ jobs: if: github.event_name != 'push' name: Audit Dependencies πŸ•΅οΈβ€β™‚οΈ uses: insightsengineering/r.pkg.template/.github/workflows/audit.yaml@main - r-cmd: + r-cmd-dev: if: github.event_name != 'push' name: R CMD Check (development) 🧬 - uses: insightsengineering/r.pkg.template/.github/workflows/build-check-install.yaml@main + uses: insightsengineering/r.pkg.template/.github/workflows/build-check-install.yaml@bci_params secrets: REPO_GITHUB_TOKEN: ${{ secrets.REPO_GITHUB_TOKEN }} with: - install-deps-from-package-repositories: "R-universe=https://pharmaverse.r-universe.dev/,CRAN=https://cloud.r-project.org/" package-subdirectory: package additional-env-vars: | NOT_CRAN=true QUARTO_PROFILE=development TLG_CATALOG_PKG_BUILD_RENDER=TRUE concurrency-group: development + deps-installation-method: setup-r-dependencies + lookup-refs: | + insightsengineering/random.cdisc.data + insightsengineering/formatters + insightsengineering/rlistings + insightsengineering/rtables + insightsengineering/tern + insightsengineering/tern.mmrm + insightsengineering/tern.rbmi + insightsengineering/teal.data + insightsengineering/teal.code + insightsengineering/teal.logger + insightsengineering/teal.reporter + insightsengineering/teal.slice + insightsengineering/teal.transform + insightsengineering/teal.widgets + insightsengineering/teal + insightsengineering/teal.modules.general + insightsengineering/teal.modules.clinical + skip-desc-dev: true + repository-list: "https://pharmaverse.r-universe.dev/, PPM@latest" + cache-version: "dev" r-cmd-stable: if: github.event_name != 'push' name: R CMD Check (stable) 🧬 - uses: insightsengineering/r.pkg.template/.github/workflows/build-check-install.yaml@main + uses: insightsengineering/r.pkg.template/.github/workflows/build-check-install.yaml@bci_params secrets: REPO_GITHUB_TOKEN: ${{ secrets.REPO_GITHUB_TOKEN }} with: - install-deps-from-package-repositories: "R-universe=https://insightsengineering.r-universe.dev/,CRAN=https://cloud.r-project.org/" package-subdirectory: package additional-env-vars: | NOT_CRAN=true QUARTO_PROFILE=stable TLG_CATALOG_PKG_BUILD_RENDER=TRUE concurrency-group: stable + deps-installation-method: setup-r-dependencies + skip-desc-dev: true + skip-desc-branch: true + cache-version: "stable" linter: if: github.event_name != 'push' name: SuperLinter πŸ¦Έβ€β™€οΈ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 28a1c7d1fe..9a4a8de9f0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -61,7 +61,6 @@ jobs: uses: actions/cache@v4 with: path: | - ~/package/.staged.dependencies book/_freeze key: ${{ runner.os }}-tlg-catalog-dev @@ -70,14 +69,15 @@ jobs: echo "gchat_webhook=${{ secrets.GCHAT_WEBHOOK }}" >> $GITHUB_ENV shell: bash - - name: Run Staged dependencies 🎦 - uses: insightsengineering/staged-dependencies-action@v1 - env: - GITHUB_PAT: ${{ secrets.REPO_GITHUB_TOKEN }} + - name: Setup R dependencies 🎦 + uses: insightsengineering/setup-r-dependencies@new_param_cache_version #@TODO: revert with: - path: "./package" - enable-check: false - direction: upstream + github-token: ${{ secrets.REPO_GITHUB_TOKEN }} + repository-path: "./package" + skip-desc-branch: true + skip-desc-dev: true + repository-list: "https://pharmaverse.r-universe.dev/, PPM@latest" + cache-version: "dev" - name: Render catalog πŸ–¨ run: | @@ -98,7 +98,7 @@ jobs: with: name: site-development path: site.zip - + - name: Remove large WebR assets 🧹 run: | packages_path <- sprintf("./book/_site/site_libs/quarto-contrib/shinylive-%s/shinylive/webr/packages", shinylive::assets_version()) @@ -112,12 +112,12 @@ jobs: unlink(x, recursive = TRUE) } } - + # refresh the `metadata.rds` file metadata_path <- file.path(packages_path, "metadata.rds") metadata <- readRDS(metadata_path) new_metadata <- metadata[intersect(names(metadata), list.dirs(packages_path, full.names = FALSE))] - saveRDS(new_metadata, metadata_path) + saveRDS(new_metadata, metadata_path) shell: Rscript {0} - name: Publish docs πŸ“” @@ -161,11 +161,15 @@ jobs: echo "gchat_webhook=${{ secrets.GCHAT_WEBHOOK }}" >> $GITHUB_ENV shell: bash - - name: Install packages 🎦 - run: | - devtools::install_dev_deps(".", repos = c("https://insightsengineering.r-universe.dev/", "https://cloud.r-project.org/")) - shell: Rscript {0} - working-directory: package + - name: Setup R dependencies 🎦 + uses: insightsengineering/setup-r-dependencies@new_param_cache_version #@TODO: revert + with: + github-token: ${{ secrets.REPO_GITHUB_TOKEN }} + repository-path: "./package" + skip-desc-branch: true + skip-desc-dev: true + repository-list: "https://insightsengineering.r-universe.dev/, PPM@latest" + cache-version: "stable" - name: Render catalog πŸ–¨ run: | @@ -179,7 +183,7 @@ jobs: zip -r9 ../../site.zip * shell: bash working-directory: book/_site - + - name: Remove large WebR assets 🧹 run: | packages_path <- sprintf("./book/_site/site_libs/quarto-contrib/shinylive-%s/shinylive/webr/packages", shinylive::assets_version()) @@ -193,12 +197,12 @@ jobs: unlink(x, recursive = TRUE) } } - + # refresh the `metadata.rds` file metadata_path <- file.path(packages_path, "metadata.rds") metadata <- readRDS(metadata_path) new_metadata <- metadata[intersect(names(metadata), list.dirs(packages_path, full.names = FALSE))] - saveRDS(new_metadata, metadata_path) + saveRDS(new_metadata, metadata_path) shell: Rscript {0} diff --git a/book/_extensions/coatless/webr/_extension.yml b/book/_extensions/coatless/webr/_extension.yml index 2c95d6eb0f..206c449f25 100644 --- a/book/_extensions/coatless/webr/_extension.yml +++ b/book/_extensions/coatless/webr/_extension.yml @@ -1,8 +1,8 @@ name: webr title: Embedded webr code cells author: James Joseph Balamuta -version: 0.4.2-dev.3 -quarto-required: ">=1.2.198" +version: 0.4.3-dev.2 +quarto-required: ">=1.4.554" contributes: filters: - webr.lua diff --git a/book/_extensions/coatless/webr/qwebr-cell-initialization.js b/book/_extensions/coatless/webr/qwebr-cell-initialization.js index 828bc94a6e..548172aeae 100644 --- a/book/_extensions/coatless/webr/qwebr-cell-initialization.js +++ b/book/_extensions/coatless/webr/qwebr-cell-initialization.js @@ -78,6 +78,10 @@ qwebrInstance.then( break; case 'setup': const activeDiv = document.getElementById(`qwebr-noninteractive-setup-area-${qwebrCounter}`); + + // Store code in history + qwebrLogCodeToHistory(cellCode, entry.options); + // Run the code in a non-interactive state with all output thrown away await mainWebR.evalRVoid(`${cellCode}`); break; diff --git a/book/_extensions/coatless/webr/qwebr-compute-engine.js b/book/_extensions/coatless/webr/qwebr-compute-engine.js index f4ad17b9f9..a35ea11a38 100644 --- a/book/_extensions/coatless/webr/qwebr-compute-engine.js +++ b/book/_extensions/coatless/webr/qwebr-compute-engine.js @@ -3,7 +3,7 @@ globalThis.qwebrIsObjectEmpty = function (arr) { return Object.keys(arr).length === 0; } -// Global version of the Escape HTML function that converts HTML +// Global version of the Escape HTML function that converts HTML // characters to their HTML entities. globalThis.qwebrEscapeHTMLCharacters = function(unsafe) { return unsafe @@ -12,7 +12,7 @@ globalThis.qwebrEscapeHTMLCharacters = function(unsafe) { .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); - }; +}; // Passthrough results globalThis.qwebrIdentity = function(x) { @@ -24,11 +24,39 @@ globalThis.qwebrPrefixComment = function(x, comment) { return `${comment}${x}`; }; +// Function to store the code in the history +globalThis.qwebrLogCodeToHistory = function(codeToRun, options) { + qwebrRCommandHistory.push( + `# Ran code in ${options.label} at ${new Date().toLocaleString()} ----\n${codeToRun}` + ); +}; + +// Function to attach a download button onto the canvas +// allowing the user to download the image. +function qwebrImageCanvasDownloadButton(canvas, canvasContainer) { + + // Create the download button + const downloadButton = document.createElement('button'); + downloadButton.className = 'qwebr-canvas-image-download-btn'; + downloadButton.textContent = 'Download Image'; + canvasContainer.appendChild(downloadButton); + + // Trigger a download of the image when the button is clicked + downloadButton.addEventListener('click', function() { + const image = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.href = image; + link.download = 'qwebr-canvas-image.png'; + link.click(); + }); +} + + // Function to parse the pager results -globalThis.qwebrParseTypePager = async function (msg) { +globalThis.qwebrParseTypePager = async function (msg) { // Split out the event data - const { path, title, deleteFile } = msg.data; + const { path, title, deleteFile } = msg.data; // Process the pager data by reading the information from disk const paged_data = await mainWebR.FS.readFile(path).then((data) => { @@ -37,7 +65,7 @@ globalThis.qwebrParseTypePager = async function (msg) { // Remove excessive backspace characters until none remain while(content.match(/.[\b]/)){ - content = content.replace(/.[\b]/g, ''); + content = content.replace(/.[\b]/g, ''); } // Returned cleaned data @@ -45,22 +73,41 @@ globalThis.qwebrParseTypePager = async function (msg) { }); // Unlink file if needed - if (deleteFile) { - await mainWebR.FS.unlink(path); - } + if (deleteFile) { + await mainWebR.FS.unlink(path); + } // Return extracted data with spaces return paged_data; -} +}; + + +// Function to parse the browse results +globalThis.qwebrParseTypeBrowse = async function (msg) { + + // msg.type === "browse" + const path = msg.data.url; + + // Process the browse data by reading the information from disk + const browse_data = await mainWebR.FS.readFile(path).then((data) => { + // Obtain the file content + let content = new TextDecoder().decode(data); + + return content; + }); + + // Return extracted data as-is + return browse_data; +}; // Function to run the code using webR and parse the output globalThis.qwebrComputeEngine = async function( - codeToRun, - elements, + codeToRun, + elements, options) { // Call into the R compute engine that persists within the document scope. - // To be prepared for all scenarios, the following happens: + // 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) @@ -80,46 +127,66 @@ globalThis.qwebrComputeEngine = async function( processOutput = qwebrIdentity; } - // ---- + // ---- // Convert from Inches to Pixels by using DPI (dots per inch) // for bitmap devices (dpi * inches = pixels) - let fig_width = options["fig-width"] * options["dpi"] - let fig_height = options["fig-height"] * options["dpi"] + let fig_width = options["fig-width"] * options["dpi"]; + let fig_height = options["fig-height"] * options["dpi"]; // Initialize webR await mainWebR.init(); + // Configure capture output + let captureOutputOptions = { + withAutoprint: true, + captureStreams: true, + captureConditions: false, + // env: webR.objs.emptyEnv, // maintain a global environment for webR v0.2.0 + }; + + // Determine if the browser supports OffScreen + if (qwebrOffScreenCanvasSupport()) { + // Mirror default options of webr::canvas() + // with changes to figure height and width. + captureOutputOptions.captureGraphics = { + width: fig_width, + height: fig_height, + bg: "white", // default: transparent + pointsize: 12, + capture: true + }; + } else { + // Disable generating graphics + captureOutputOptions.captureGraphics = false; + } + + // Store the code to run in history + qwebrLogCodeToHistory(codeToRun, options); + // Setup a webR canvas by making a namespace call into the {webr} package // Evaluate the R code // Remove the active canvas silently const result = await mainWebRCodeShelter.captureR( - `webr::canvas(width=${fig_width}, height=${fig_height}, capture = TRUE) - .webr_cvs_id <- dev.cur() - ${codeToRun} - invisible(dev.off(.webr_cvs_id)) - `, { - withAutoprint: true, - captureStreams: true, - captureConditions: false//, - // env: webR.objs.emptyEnv, // maintain a global environment for webR v0.2.0 - }); + `${codeToRun}`, + captureOutputOptions + ); // ----- // Start attempting to parse the result data processResultOutput:try { - + // Avoid running through output processing - if (options.results === "hide" || options.output === "false") { - break processResultOutput; + if (options.results === "hide" || options.output === "false") { + break processResultOutput; } // Merge output streams of STDOUT and STDErr (messages and errors are combined.) - // Require both `warning` and `message` to be true to display `STDErr`. + // Require both `warning` and `message` to be true to display `STDErr`. const out = result.output .filter( - evt => evt.type === "stdout" || - ( evt.type === "stderr" && (options.warning === "true" && options.message === "true")) + evt => evt.type === "stdout" || + ( evt.type === "stderr" && (options.warning === "true" && options.message === "true")) ) .map((evt, index) => { const className = `qwebr-output-code-${evt.type}`; @@ -131,15 +198,31 @@ globalThis.qwebrComputeEngine = async function( // Clean the state // We're now able to process pager events. - // As a result, we cannot maintain a true 1-to-1 output order + // As a result, we cannot maintain a true 1-to-1 output order // without individually feeding each line const msgs = await mainWebR.flush(); // Use `map` to process the filtered "pager" events asynchronously - const pager = await Promise.all( - msgs.filter(msg => msg.type === 'pager').map( + const pager = []; + const browse = []; + + await Promise.all( + msgs.map( async (msg) => { - return await qwebrParseTypePager(msg); + + const msgType = msg.type || "unknown"; + + switch(msgType) { + case 'pager': + const pager_data = await qwebrParseTypePager(msg); + pager.push(pager_data); + break; + case 'browse': + const browse_data = await qwebrParseTypeBrowse(msg); + browse.push(browse_data); + break; + } + return; } ) ); @@ -171,14 +254,20 @@ globalThis.qwebrComputeEngine = async function( // Determine if we have graphs to display if (result.images.length > 0) { + // Create figure element - const figureElement = document.createElement('figure'); + const figureElement = document.createElement("figure"); + figureElement.className = "qwebr-canvas-image"; // Place each rendered graphic onto a canvas element result.images.forEach((img) => { + // Construct canvas for object const canvas = document.createElement("canvas"); + // Add an image download button + qwebrImageCanvasDownloadButton(canvas, figureElement); + // Set canvas size to image canvas.width = img.width; canvas.height = img.height; @@ -196,9 +285,10 @@ globalThis.qwebrComputeEngine = async function( // Draw image onto Canvas const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, img.width, img.height); - + // Append canvas to figure output area figureElement.appendChild(canvas); + }); if (options['fig-cap']) { @@ -206,28 +296,46 @@ globalThis.qwebrComputeEngine = async function( const figcaptionElement = document.createElement('figcaption'); figcaptionElement.innerText = options['fig-cap']; // Append figcaption to figure - figureElement.appendChild(figcaptionElement); + figureElement.appendChild(figcaptionElement); } elements.outputGraphDiv.appendChild(figureElement); + } // 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); - }); + if (pager.length > 0) { + // Use the `pre` element to preserve whitespace. + pager.forEach((paged_data, index) => { + const 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); + }); + } + + // Display the browse data + if (browse.length > 0) { + // Use the `pre` element to preserve whitespace. + browse.forEach((browse_data, index) => { + const iframe_browse = document.createElement('iframe'); + iframe_browse.classList.add("qwebr-output-code-browse"); + iframe_browse.setAttribute("id", `qwebr-output-code-browse-editor-${elements.id}-result-${index + 1}`); + iframe_browse.style.width = "100%"; + iframe_browse.style.minHeight = "500px"; + elements.outputCodeDiv.appendChild(iframe_browse); + + iframe_browse.contentWindow.document.open(); + iframe_browse.contentWindow.document.write(browse_data); + iframe_browse.contentWindow.document.close(); + }); } } finally { // Clean up the remaining code mainWebRCodeShelter.purge(); } -} +}; // Function to execute the code (accepts code as an argument) globalThis.qwebrExecuteCode = async function ( @@ -237,12 +345,12 @@ globalThis.qwebrExecuteCode = async function ( // If options are not passed, we fall back on the bare minimum to handle the computation if (qwebrIsObjectEmpty(options)) { - options = { - "context": "interactive", - "fig-width": 7, "fig-height": 5, - "out-width": "700px", "out-height": "", + options = { + "context": "interactive", + "fig-width": 7, "fig-height": 5, + "out-width": "700px", "out-height": "", "dpi": 72, - "results": "markup", + "results": "markup", "warning": "true", "message": "true", }; } diff --git a/book/_extensions/coatless/webr/qwebr-document-engine-initialization.js b/book/_extensions/coatless/webr/qwebr-document-engine-initialization.js index 1d447e8bdb..723220acc0 100644 --- a/book/_extensions/coatless/webr/qwebr-document-engine-initialization.js +++ b/book/_extensions/coatless/webr/qwebr-document-engine-initialization.js @@ -58,11 +58,15 @@ globalThis.qwebrInstance = import(qwebrCustomizedWebROptions.baseURL + "webr.mjs // Setup a shelter globalThis.mainWebRCodeShelter = await new mainWebR.Shelter(); - // Setup a pager to allow processing help documentation - await mainWebR.evalRVoid('webr::pager_install()'); + // Setup a pager to allow processing help documentation + await mainWebR.evalRVoid('webr::pager_install()'); + + // Setup a viewer to allow processing htmlwidgets. + // This might not be available in old webr version + await mainWebR.evalRVoid('try({ webr::viewer_install() })'); // Override the existing install.packages() to use webr::install() - await mainWebR.evalRVoid('webr::shim_install()'); + await mainWebR.evalRVoid('webr::shim_install()'); // Specify the repositories to pull from // Note: webR does not use the `repos` option, but instead uses `webr_pkg_repos` diff --git a/book/_extensions/coatless/webr/qwebr-document-history.js b/book/_extensions/coatless/webr/qwebr-document-history.js new file mode 100644 index 0000000000..df00091132 --- /dev/null +++ b/book/_extensions/coatless/webr/qwebr-document-history.js @@ -0,0 +1,110 @@ +// Define a global storage and retrieval solution ---- + +// Store commands executed in R +globalThis.qwebrRCommandHistory = []; + +// Function to retrieve the command history +globalThis.qwebrFormatRHistory = function() { + return qwebrRCommandHistory.join("\n\n"); +} + +// Retrieve HTML Elements ---- + +// Get the command modal +const command_history_modal = document.getElementById("qwebr-history-modal"); + +// Get the button that opens the command modal +const command_history_btn = document.getElementById("qwebrRHistoryButton"); + +// Get the element that closes the command modal +const command_history_close_span = document.getElementById("qwebr-command-history-close-btn"); + +// Get the download button for r history information +const command_history_download_btn = document.getElementById("qwebr-download-history-btn"); + +// Plug in command history into modal/download button ---- + +// Function to populate the modal with command history +function populateCommandHistoryModal() { + document.getElementById("qwebr-command-history-contents").innerHTML = qwebrFormatRHistory() || "No commands have been executed yet."; +} + +// Function to format the current date and time to +// a string with the format YYYY-MM-DD-HH-MM-SS +function formatDateTime() { + const now = new Date(); + + const year = now.getFullYear(); + const day = String(now.getDate()).padStart(2, '0'); + const month = String(now.getMonth() + 1).padStart(2, '0'); // Months are zero-based + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`; +} + + +// Function to convert document title with datetime to a safe filename +function safeFileName() { + // Get the current page title + let pageTitle = document.title; + + // Combine the current page title with the current date and time + let pageNameWithDateTime = `Rhistory-${pageTitle}-${formatDateTime()}`; + + // Replace unsafe characters with safe alternatives + let safeFilename = pageNameWithDateTime.replace(/[\\/:\*\?! "<>\|]/g, '-'); + + return safeFilename; +} + + +// Function to download list contents as text file +function downloadRHistory() { + // Get the current page title + datetime and use it as the filename + const filename = `${safeFileName()}.R`; + + // Get the text contents of the R History list + const text = qwebrFormatRHistory(); + + // Create a new Blob object with the text contents + const blob = new Blob([text], { type: 'text/plain' }); + + // Create a new anchor element for the download + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = URL.createObjectURL(blob); + a.download = filename; + + // Append the anchor to the body, click it, and remove it + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +// Register event handlers ---- + +// When the user clicks the View R History button, open the command modal +command_history_btn.onclick = function() { + populateCommandHistoryModal(); + command_history_modal.style.display = "block"; +} + +// When the user clicks on (x), close the command modal +command_history_close_span.onclick = function() { + command_history_modal.style.display = "none"; +} + +// When the user clicks anywhere outside of the command modal, close it +window.onclick = function(event) { + if (event.target == command_history_modal) { + command_history_modal.style.display = "none"; + } +} + +// Add an onclick event listener to the download button so that +// the user can download the R history as a text file +command_history_download_btn.onclick = function() { + downloadRHistory(); +}; \ No newline at end of file diff --git a/book/_extensions/coatless/webr/qwebr-document-status.js b/book/_extensions/coatless/webr/qwebr-document-status.js index c13a5f5277..71441df408 100644 --- a/book/_extensions/coatless/webr/qwebr-document-status.js +++ b/book/_extensions/coatless/webr/qwebr-document-status.js @@ -1,6 +1,10 @@ // Declare startupMessageQWebR globally globalThis.qwebrStartupMessage = document.createElement("p"); +// Verify if OffScreenCanvas is supported +globalThis.qwebrOffScreenCanvasSupport = function() { + return typeof OffscreenCanvas !== 'undefined' +} // Function to set the button text globalThis.qwebrSetInteractiveButtonState = function(buttonText, enableCodeButton = true) { @@ -24,16 +28,123 @@ globalThis.qwebrUpdateStatusHeader = function(message) { ${message}`; } +// Function to return true if element is found, false if not +globalThis.qwebrCheckHTMLElementExists = function(selector) { + const element = document.querySelector(selector); + return !!element; +} + +// Function that detects whether reveal.js slides are present +globalThis.qwebrIsRevealJS = function() { + // If the '.reveal .slides' selector exists, RevealJS is likely present + return qwebrCheckHTMLElementExists('.reveal .slides'); +} + +// Initialize the Quarto sidebar element +function qwebrSetupQuartoSidebar() { + var newSideBarDiv = document.createElement('div'); + newSideBarDiv.id = 'quarto-margin-sidebar'; + newSideBarDiv.className = 'sidebar margin-sidebar'; + newSideBarDiv.style.top = '0px'; + newSideBarDiv.style.maxHeight = 'calc(0px + 100vh)'; + + return newSideBarDiv; +} + +// Position the sidebar in the document +function qwebrPlaceQuartoSidebar() { + // Get the reference to the element with id 'quarto-document-content' + var referenceNode = document.getElementById('quarto-document-content'); + + // Create the new div element + var newSideBarDiv = qwebrSetupQuartoSidebar(); + + // Insert the new div before the 'quarto-document-content' element + referenceNode.parentNode.insertBefore(newSideBarDiv, referenceNode); +} + +function qwebrPlaceMessageContents(content, html_location = "title-block-header", revealjs_location = "title-slide") { + + // Get references to header elements + const headerHTML = document.getElementById(html_location); + const headerRevealJS = document.getElementById(revealjs_location); + + // Determine where to insert the quartoTitleMeta element + if (headerHTML || headerRevealJS) { + // Append to the existing "title-block-header" element or "title-slide" div + (headerHTML || headerRevealJS).appendChild(content); + } 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(content); + monacoScript.after(header); + } +} + + + +function qwebrOffScreenCanvasSupportWarningMessage() { + + // Verify canvas is supported. + if(qwebrOffScreenCanvasSupport()) return; + + // Create the main container div + var calloutContainer = document.createElement('div'); + calloutContainer.classList.add('callout', 'callout-style-default', 'callout-warning', 'callout-titled'); + + // Create the header div + var headerDiv = document.createElement('div'); + headerDiv.classList.add('callout-header', 'd-flex', 'align-content-center'); + + // Create the icon container div + var iconContainer = document.createElement('div'); + iconContainer.classList.add('callout-icon-container'); + + // Create the icon element + var iconElement = document.createElement('i'); + iconElement.classList.add('callout-icon'); + + // Append the icon element to the icon container + iconContainer.appendChild(iconElement); + + // Create the title container div + var titleContainer = document.createElement('div'); + titleContainer.classList.add('callout-title-container', 'flex-fill'); + titleContainer.innerText = 'Warning: Web Browser Does Not Support Graphing!'; + + // Append the icon container and title container to the header div + headerDiv.appendChild(iconContainer); + headerDiv.appendChild(titleContainer); + + // Create the body container div + var bodyContainer = document.createElement('div'); + bodyContainer.classList.add('callout-body-container', 'callout-body'); + + // Create the paragraph element for the body content + var paragraphElement = document.createElement('p'); + paragraphElement.innerHTML = 'This web browser does not have support for displaying graphs through the quarto-webr extension since it lacks an OffScreenCanvas. Please upgrade your web browser to one that supports OffScreenCanvas.'; + + // Append the paragraph element to the body container + bodyContainer.appendChild(paragraphElement); + + // Append the header div and body container to the main container div + calloutContainer.appendChild(headerDiv); + calloutContainer.appendChild(bodyContainer); + + // Append the main container div to the document depending on format + qwebrPlaceMessageContents(calloutContainer, "title-block-header"); + +} + + // Function that attaches the document status message and diagnostics function displayStartupMessage(showStartupMessage, showHeaderMessage) { if (!showStartupMessage) { return; } - // 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"); @@ -75,18 +186,183 @@ function displayStartupMessage(showStartupMessage, showHeaderMessage) { firstInnerDiv.appendChild(secondInnerDivContents); quartoTitleMeta.appendChild(firstInnerDiv); - // Determine where to insert the quartoTitleMeta element - if (headerHTML || headerRevealJS) { - // Append to the existing "title-block-header" element or "title-slide" div - (headerHTML || headerRevealJS).appendChild(quartoTitleMeta); + // Place message on webpage + qwebrPlaceMessageContents(quartoTitleMeta); +} + +function qwebrAddCommandHistoryModal() { + // Create the modal div + var modalDiv = document.createElement('div'); + modalDiv.id = 'qwebr-history-modal'; + modalDiv.className = 'qwebr-modal'; + + // Create the modal content div + var modalContentDiv = document.createElement('div'); + modalContentDiv.className = 'qwebr-modal-content'; + + // Create the span for closing the modal + var closeSpan = document.createElement('span'); + closeSpan.id = 'qwebr-command-history-close-btn'; + closeSpan.className = 'qwebr-modal-close'; + closeSpan.innerHTML = '×'; + + // Create the h1 element for the modal + var modalH1 = document.createElement('h1'); + modalH1.textContent = 'R History Command Contents'; + + // Create an anchor element for downloading the Rhistory file + var downloadLink = document.createElement('a'); + downloadLink.href = '#'; + downloadLink.id = 'qwebr-download-history-btn'; + downloadLink.className = 'qwebr-download-btn'; + + // Create an 'i' element for the icon + var icon = document.createElement('i'); + icon.className = 'bi bi-file-code'; + + // Append the icon to the anchor element + downloadLink.appendChild(icon); + + // Add the text 'Download R History' to the anchor element + downloadLink.appendChild(document.createTextNode(' Download R History File')); + + // Create the pre for command history contents + var commandContentsPre = document.createElement('pre'); + commandContentsPre.id = 'qwebr-command-history-contents'; + commandContentsPre.className = 'qwebr-modal-content-code'; + + // Append the close span, h1, and history contents pre to the modal content div + modalContentDiv.appendChild(closeSpan); + modalContentDiv.appendChild(modalH1); + modalContentDiv.appendChild(downloadLink); + modalContentDiv.appendChild(commandContentsPre); + + // Append the modal content div to the modal div + modalDiv.appendChild(modalContentDiv); + + // Append the modal div to the body + document.body.appendChild(modalDiv); +} + +function qwebrRegisterRevealJSCommandHistoryModal() { + // Select the