diff --git a/inst/js/app/btw_app.css b/inst/js/app/btw_app.css index 538bb6c9..beb5f44c 100644 --- a/inst/js/app/btw_app.css +++ b/inst/js/app/btw_app.css @@ -144,42 +144,46 @@ pre:has(.code-action-wrapper) { cursor: pointer; } -.code-action-wrapper .code-copy-button { +.code-action-wrapper .code-copy-button, +.code-action-wrapper .btw-block-copy-btn { position: static; top: auto; right: auto; margin-top: 0; margin-right: 0; + opacity: 1; + width: auto; + height: auto; +} + +.code-action-wrapper > button > .bi::after { + content: ""; + display: block; + width: 1rem; + height: 1rem; + background-color: var(--bs-body-color, #222); } /* @vscode/codicons: copy, insert-at-cursor, new-file, terminal (MIT) */ -.code-copy-button > .bi::after { +.code-copy-button > .bi::after, +.btw-block-copy-btn > .bi::after { mask-image: url('data:image/svg+xml,'); } +.btw-block-copy-btn-checked > .bi::after { + mask-image: url('data:image/svg+xml,'); + background-color: var(--bs-success, #198754); +} + .insert-at-cursor-button > .bi::after { - content: ""; - display: block; - height: 1rem; - width: 1rem; - background-color: var(--bs-body-color, #222); mask-image: url('data:image/svg+xml,'); } .insert-new-file-button > .bi::after { - content: ""; - display: block; - height: 1rem; - width: 1rem; background-color: var(--bs-body-color, #222); mask-image: url('data:image/svg+xml,'); } .insert-console-button > .bi::after { - content: ""; - display: block; - height: 1rem; - width: 1rem; - background-color: var(--bs-body-color, #222); mask-image: url('data:image/svg+xml,'); } diff --git a/inst/js/app/btw_app.js b/inst/js/app/btw_app.js index fc06c84a..042f42a1 100644 --- a/inst/js/app/btw_app.js +++ b/inst/js/app/btw_app.js @@ -85,12 +85,21 @@ const inIframe = window.self !== window.top const inIDE = document.querySelector(".btw-in-ide") !== null if (inIframe && inIDE) { + // Matches both old Lit (shiny-markdown-stream) and new React (.shiny-chat-message-content) + const STREAM_SELECTOR = "shiny-markdown-stream, .shiny-chat-message-content" + const stopObserving = observeShinyMarkdownStream((streamEl) => { enhanceCodeActions(streamEl) }) + // New React shinychat: btw-run-r-result dispatches this event after rendering + // its per-block copy buttons. Add IDE action buttons to source code blocks only. + document.addEventListener("btw-run-r-rendered", (e) => { + enhanceBtwCodeActions(e.target) + }) + function isMarkdownStream(el) { - return el.matches("shiny-markdown-stream") + return el.matches(STREAM_SELECTOR) } function canEnhance(el) { @@ -107,6 +116,11 @@ if (inIframe && inIDE) { function attachObserver(el) { if (!isMarkdownStream(el) || observers.has(el)) return + // Enhance any content already in the element. In React shinychat the + // element arrives fully populated in one commit, so the per-element + // observer below would never fire for that initial content. + callback(el) + let timeoutId = null const observer = new MutationObserver(() => { @@ -123,7 +137,7 @@ if (inIframe && inIDE) { observers.set(el, observer) } - document.querySelectorAll("shiny-markdown-stream").forEach(attachObserver) + document.querySelectorAll(STREAM_SELECTOR).forEach(attachObserver) const rootObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { @@ -135,7 +149,7 @@ if (inIframe && inIDE) { } node - .querySelectorAll?.("shiny-markdown-stream") + .querySelectorAll?.(STREAM_SELECTOR) .forEach(attachObserver) }) }) @@ -159,8 +173,23 @@ if (inIframe && inIDE) { if (!pre || !canEnhance(pre)) return const wrapper = ensureWrapper(pre) - moveCopyButton(wrapper, copyButton) installActionButtons(wrapper, pre) + moveCopyButton(wrapper, copyButton) + }) + } + + function enhanceBtwCodeActions(result) { + result.querySelectorAll(".btw-block-copy-btn").forEach((copyButton) => { + const pre = copyButton.closest("pre") + if (!pre) return + + const wrapper = ensureWrapper(pre) + + // Install IDE action buttons first, then copy button so it appears last + if (pre.closest(".btw-output-source")) { + installActionButtons(wrapper, pre) + } + moveCopyButton(wrapper, copyButton) }) } diff --git a/inst/js/run-r/btw-run-r.css b/inst/js/run-r/btw-run-r.css index 02a6a90f..3bb2f221 100644 --- a/inst/js/run-r/btw-run-r.css +++ b/inst/js/run-r/btw-run-r.css @@ -29,6 +29,7 @@ btw-run-r-result .card-body { border: none; border-radius: 0; overflow-x: auto; + position: relative; } .btw-run-output pre:not(.btw-output-source) { @@ -41,11 +42,59 @@ btw-run-r-result .card-body { background-color: var(--bs-black); } -.btw-run-output > :not(.btw-output-source) .code-copy-button { - /* TODO: Figure out how to disable markdown-stream code copy button */ +.btw-run-output .code-copy-button { + /* Hide shinychat's pipeline copy button — btw adds its own via addBlockCopyButtons() */ display: none; } +/* Per-block copy buttons */ +.btw-block-copy-btn { + position: absolute; + top: 0.25rem; + right: 0.25rem; + width: 1.5rem; + height: 1.5rem; + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--bs-body-color); + opacity: 0; + transition: opacity 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; +} + +.btw-run-output pre:hover .btw-block-copy-btn { + opacity: 0.6; +} + +.btw-block-copy-btn:hover { + opacity: 1 !important; +} + +.btw-block-copy-btn:focus { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; + opacity: 1; +} + +.btw-block-copy-btn > .bi::after { + content: ""; + display: block; + width: 1rem; + height: 1rem; + background-color: var(--bs-body-color, #222); + mask-image: url('data:image/svg+xml,'); +} + +.btw-block-copy-btn-checked > .bi::after { + mask-image: url('data:image/svg+xml,'); + background-color: var(--bs-success, #198754); +} + .btw-run-output pre code { font-family: var(--bs-font-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); white-space: pre; diff --git a/inst/js/run-r/btw-run-r.js b/inst/js/run-r/btw-run-r.js index 8a6c17ba..871f01e0 100644 --- a/inst/js/run-r/btw-run-r.js +++ b/inst/js/run-r/btw-run-r.js @@ -332,6 +332,8 @@ class BtwRunRResult extends HTMLElement { } } + this.addBlockCopyButtons() + // Allow clicking anywhere on the header to toggle, except on action buttons const header = this.querySelector(".card-header") if (header) { @@ -348,6 +350,43 @@ class BtwRunRResult extends HTMLElement { } } + /** + * Add per-block copy buttons to each pre element in the output container. + * Idempotent: skips any pre that already has a button (safe to call after re-render). + */ + addBlockCopyButtons() { + const outputContainer = this.querySelector(".btw-run-output") + if (!outputContainer) return + + outputContainer.querySelectorAll("pre").forEach((pre) => { + if (pre.querySelector(".btw-block-copy-btn")) return + + const btn = document.createElement("button") + btn.className = "btw-block-copy-btn" + btn.setAttribute("aria-label", "Copy to clipboard") + btn.innerHTML = '' + + btn.addEventListener("click", async (e) => { + e.stopPropagation() + const code = pre.querySelector("code") + const text = code?.textContent ?? pre.textContent ?? "" + try { + await copyToClipboard(text) + btn.classList.add("btw-block-copy-btn-checked") + setTimeout(() => { + btn.classList.remove("btw-block-copy-btn-checked") + }, 1500) + } catch (err) { + console.error("Failed to copy:", err) + } + }) + + pre.appendChild(btn) + }) + + this.dispatchEvent(new CustomEvent("btw-run-r-rendered", { bubbles: true })) + } + /** * Escape a string for use in an HTML attribute * @param {string} str