Release 0.2.0 #4
@@ -440,6 +440,45 @@
|
|||||||
|
|
||||||
.copilot-markdown pre code { background: none; padding: 0; font-size: inherit; }
|
.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 <pre> 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 {
|
.copilot-markdown blockquote {
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export default {
|
|||||||
'chat.stop': 'Stop',
|
'chat.stop': 'Stop',
|
||||||
'chat.thinking': 'Thinking…',
|
'chat.thinking': 'Thinking…',
|
||||||
'chat.reasoning': 'Reasoning…',
|
'chat.reasoning': 'Reasoning…',
|
||||||
|
'chat.copy_code': 'Copy code',
|
||||||
|
'chat.copied': 'Copied',
|
||||||
'chat.attach': 'Attach files',
|
'chat.attach': 'Attach files',
|
||||||
'chat.new_session': 'New conversation',
|
'chat.new_session': 'New conversation',
|
||||||
'chat.scroll_to_latest': 'Scroll to latest',
|
'chat.scroll_to_latest': 'Scroll to latest',
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export default {
|
|||||||
'chat.stop': 'Arrêter',
|
'chat.stop': 'Arrêter',
|
||||||
'chat.thinking': 'Réflexion…',
|
'chat.thinking': 'Réflexion…',
|
||||||
'chat.reasoning': 'Raisonnement…',
|
'chat.reasoning': 'Raisonnement…',
|
||||||
|
'chat.copy_code': 'Copier le code',
|
||||||
|
'chat.copied': 'Copié',
|
||||||
'chat.attach': 'Joindre des fichiers',
|
'chat.attach': 'Joindre des fichiers',
|
||||||
'chat.new_session': 'Nouvelle conversation',
|
'chat.new_session': 'Nouvelle conversation',
|
||||||
'chat.scroll_to_latest': 'Aller aux derniers messages',
|
'chat.scroll_to_latest': 'Aller aux derniers messages',
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export default {
|
|||||||
'chat.stop': 'Ferma',
|
'chat.stop': 'Ferma',
|
||||||
'chat.thinking': 'Sto pensando…',
|
'chat.thinking': 'Sto pensando…',
|
||||||
'chat.reasoning': 'Ragionamento…',
|
'chat.reasoning': 'Ragionamento…',
|
||||||
|
'chat.copy_code': 'Copia codice',
|
||||||
|
'chat.copied': 'Copiato',
|
||||||
'chat.attach': 'Allega file',
|
'chat.attach': 'Allega file',
|
||||||
'chat.new_session': 'Nuova conversazione',
|
'chat.new_session': 'Nuova conversazione',
|
||||||
'chat.scroll_to_latest': 'Vai agli ultimi messaggi',
|
'chat.scroll_to_latest': 'Vai agli ultimi messaggi',
|
||||||
|
|||||||
+49
-1
@@ -1,6 +1,7 @@
|
|||||||
import { LitElement } from 'lit';
|
import { LitElement } from 'lit';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import { t } from './i18n.js';
|
||||||
|
|
||||||
marked.use({ breaks: true, gfm: true });
|
marked.use({ breaks: true, gfm: true });
|
||||||
|
|
||||||
@@ -34,7 +35,54 @@ DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
|||||||
export function renderMarkdown(text) {
|
export function renderMarkdown(text) {
|
||||||
// `target` is not in DOMPurify's default attribute allow-list, so the
|
// `target` is not in DOMPurify's default attribute allow-list, so the
|
||||||
// external-link hook above needs it whitelisted here to survive sanitization.
|
// 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. `<pre>` 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 = `<button type="button" class="md-code-copy" title="${t('chat.copy_code')}"><i class="bi bi-clipboard"></i></button>`;
|
||||||
|
return html.replaceAll('<pre>', `<div class="md-code-wrap">${btn}<pre>`)
|
||||||
|
.replaceAll('</pre>', '</pre></div>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Disable Shadow DOM so Bootstrap CSS flows through naturally.
|
||||||
|
|||||||
Reference in New Issue
Block a user