diff --git a/web/css/copilot-messages.css b/web/css/copilot-messages.css index b1ae27f..23bd881 100644 --- a/web/css/copilot-messages.css +++ b/web/css/copilot-messages.css @@ -440,6 +440,45 @@ .copilot-markdown pre code { background: none; padding: 0; font-size: inherit; } +/* Fenced code blocks carry a copy button that appears on hover (renderMarkdown + wraps every
 in .md-code-wrap). */
+.md-code-wrap { position: relative; }
+
+.md-code-copy {
+  position: absolute;
+  top: 0.4rem;
+  right: 0.4rem;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 1.6rem;
+  height: 1.6rem;
+  padding: 0;
+  border: none;
+  border-radius: 0.35rem;
+  background: rgba(0, 0, 0, 0.12);
+  color: inherit;
+  font-size: 0.8rem;
+  line-height: 1;
+  cursor: pointer;
+  opacity: 0;
+  transition: opacity 0.15s ease;
+}
+
+.md-code-wrap:hover .md-code-copy,
+.md-code-copy:focus-visible { opacity: 1; }
+
+.md-code-copy.copied { opacity: 1; color: #16a34a; }
+
+@media (prefers-color-scheme: dark) {
+  .md-code-copy { background: rgba(255, 255, 255, 0.14); }
+}
+
+/* No hover on touch: keep the button dimly visible so it stays reachable. */
+@media (hover: none) {
+  .md-code-copy { opacity: 0.55; }
+}
+
 .copilot-markdown blockquote {
   border-left: 3px solid var(--accent);
   margin: 0.5rem 0;
diff --git a/web/i18n/en.js b/web/i18n/en.js
index 1c2c47a..ff6b74a 100644
--- a/web/i18n/en.js
+++ b/web/i18n/en.js
@@ -66,6 +66,8 @@ export default {
   'chat.stop':          'Stop',
   'chat.thinking':      'Thinking…',
   'chat.reasoning':     'Reasoning…',
+  'chat.copy_code':     'Copy code',
+  'chat.copied':        'Copied',
   'chat.attach':        'Attach files',
   'chat.new_session':   'New conversation',
   'chat.scroll_to_latest': 'Scroll to latest',
diff --git a/web/i18n/fr.js b/web/i18n/fr.js
index ca27aec..99e5ad8 100644
--- a/web/i18n/fr.js
+++ b/web/i18n/fr.js
@@ -66,6 +66,8 @@ export default {
   'chat.stop':          'Arrêter',
   'chat.thinking':      'Réflexion…',
   'chat.reasoning':     'Raisonnement…',
+  'chat.copy_code':     'Copier le code',
+  'chat.copied':        'Copié',
   'chat.attach':        'Joindre des fichiers',
   'chat.new_session':   'Nouvelle conversation',
   'chat.scroll_to_latest': 'Aller aux derniers messages',
diff --git a/web/i18n/it.js b/web/i18n/it.js
index 8e09881..1def61c 100644
--- a/web/i18n/it.js
+++ b/web/i18n/it.js
@@ -66,6 +66,8 @@ export default {
   'chat.stop':          'Ferma',
   'chat.thinking':      'Sto pensando…',
   'chat.reasoning':     'Ragionamento…',
+  'chat.copy_code':     'Copia codice',
+  'chat.copied':        'Copiato',
   'chat.attach':        'Allega file',
   'chat.new_session':   'Nuova conversazione',
   'chat.scroll_to_latest': 'Vai agli ultimi messaggi',
diff --git a/web/lib/base.js b/web/lib/base.js
index c08a1f6..919894c 100644
--- a/web/lib/base.js
+++ b/web/lib/base.js
@@ -1,6 +1,7 @@
 import { LitElement } from 'lit';
 import { marked }     from 'marked';
 import DOMPurify      from 'dompurify';
+import { t }          from './i18n.js';
 
 marked.use({ breaks: true, gfm: true });
 
@@ -34,7 +35,54 @@ DOMPurify.addHook('uponSanitizeElement', (node, data) => {
 export function renderMarkdown(text) {
   // `target` is not in DOMPurify's default attribute allow-list, so the
   // external-link hook above needs it whitelisted here to survive sanitization.
-  return DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
+  const html = DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
+  // Wrap fenced code blocks in .md-code-wrap so a copy button can float over
+  // them on hover. `
` reaches this point only from a marked code block —
+  // a literal one in the source text is escaped by sanitize, so the string
+  // replace cannot wrap anything else.
+  const btn = ``;
+  return html.replaceAll('
', `
${btn}
`)
+             .replaceAll('
', '
'); +} + +// One delegated listener serves every copy button renderMarkdown has ever +// emitted: the buttons live inside `unsafeHTML` fragments, so no per-element +// handler could be attached at render time. +document.addEventListener('click', (ev) => { + const btn = ev.target.closest?.('.md-code-copy'); + if (!btn) return; + const pre = btn.closest('.md-code-wrap')?.querySelector('pre'); + if (!pre) return; + copyToClipboard(pre.textContent ?? '').then((ok) => { + if (!ok) return; + const icon = btn.querySelector('i'); + btn.classList.add('copied'); + btn.title = t('chat.copied'); + icon?.classList.replace('bi-clipboard', 'bi-check'); + setTimeout(() => { + btn.classList.remove('copied'); + btn.title = t('chat.copy_code'); + icon?.classList.replace('bi-check', 'bi-clipboard'); + }, 1500); + }); +}); + +async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // The Clipboard API requires a secure context; a plain-http LAN box needs + // the legacy fallback. + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;opacity:0'; + document.body.appendChild(ta); + ta.select(); + try { return document.execCommand('copy'); } + catch { return false; } + finally { ta.remove(); } + } } // Disable Shadow DOM so Bootstrap CSS flows through naturally.