feat(web): tap-to-copy button on every chat message
Each user and assistant message gets a copy button (⧉ → ✓) that puts the whole message on the clipboard. Assistant copies the raw markdown (its dataset.raw); user copies its text. - copyToClipboard uses the async Clipboard API when available and falls back to a hidden-textarea + execCommand with an explicit iOS selection range, so it works on the iPhone PWA over plain-HTTP LAN (no secure context). - Copy sits in the assistant rate-bar and in a right-aligned bar on user bubbles; tools stay visible on touch (@media hover:none) since there's no hover on iOS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -473,6 +473,7 @@
|
|||||||
up.addEventListener("click", () => rateMessage(div, 1, up, down));
|
up.addEventListener("click", () => rateMessage(div, 1, up, down));
|
||||||
down.addEventListener("click", () => rateMessage(div, -1, up, down));
|
down.addEventListener("click", () => rateMessage(div, -1, up, down));
|
||||||
bar.appendChild(up); bar.appendChild(down);
|
bar.appendChild(up); bar.appendChild(down);
|
||||||
|
bar.appendChild(makeCopyBtn(() => div.dataset.raw || div.textContent || ""));
|
||||||
div.appendChild(bar);
|
div.appendChild(bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,6 +492,62 @@
|
|||||||
down.classList.toggle("rated", value === -1);
|
down.classList.toggle("rated", value === -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy text to the clipboard. Uses the async Clipboard API when available
|
||||||
|
// (HTTPS / localhost), and falls back to a hidden-textarea + execCommand for
|
||||||
|
// iOS over plain-HTTP LAN (where navigator.clipboard is undefined).
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
text = text == null ? "" : String(text);
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
|
||||||
|
}
|
||||||
|
return legacyCopy(text);
|
||||||
|
}
|
||||||
|
function legacyCopy(text) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.contentEditable = "true";
|
||||||
|
ta.readOnly = false;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.top = "0";
|
||||||
|
ta.style.left = "0";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(ta);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
ta.setSelectionRange(0, text.length); // iOS needs an explicit range
|
||||||
|
let ok = false;
|
||||||
|
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
|
||||||
|
sel.removeAllRanges();
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
ok ? resolve() : reject(new Error("copy failed"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A small per-message copy button. getText is read at click time.
|
||||||
|
function makeCopyBtn(getText) {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.className = "copy-btn";
|
||||||
|
b.type = "button";
|
||||||
|
b.textContent = "⧉";
|
||||||
|
b.title = "Copy message";
|
||||||
|
b.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(typeof getText === "function" ? getText() : getText)
|
||||||
|
.then(() => {
|
||||||
|
b.textContent = "✓"; b.classList.add("copied");
|
||||||
|
setTimeout(() => { b.textContent = "⧉"; b.classList.remove("copied"); }, 1200);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
b.textContent = "✗";
|
||||||
|
setTimeout(() => { b.textContent = "⧉"; }, 1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
function addMessage(role, text, autoScroll = true) {
|
function addMessage(role, text, autoScroll = true) {
|
||||||
const messagesEl = document.getElementById("messages");
|
const messagesEl = document.getElementById("messages");
|
||||||
|
|
||||||
@@ -502,6 +559,12 @@
|
|||||||
addRateBar(msgDiv);
|
addRateBar(msgDiv);
|
||||||
} else {
|
} else {
|
||||||
msgDiv.textContent = text;
|
msgDiv.textContent = text;
|
||||||
|
if (role === "user") {
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "rate-bar";
|
||||||
|
bar.appendChild(makeCopyBtn(() => text));
|
||||||
|
msgDiv.appendChild(bar);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
messagesEl.appendChild(msgDiv);
|
messagesEl.appendChild(msgDiv);
|
||||||
|
|
||||||
|
|||||||
@@ -1182,6 +1182,22 @@ select:hover {
|
|||||||
.rate-btn:hover { filter: none; background: var(--accent-soft); }
|
.rate-btn:hover { filter: none; background: var(--accent-soft); }
|
||||||
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.22); opacity: 1; }
|
.rate-btn.rated { filter: none; background: rgba(255,122,0,0.22); opacity: 1; }
|
||||||
|
|
||||||
|
/* Per-message copy button (lives in the rate-bar for assistant, its own bar for user). */
|
||||||
|
.copy-btn {
|
||||||
|
background: none; border: none; cursor: pointer; font-size: 0.85rem;
|
||||||
|
padding: 2px 6px; border-radius: 5px; line-height: 1; color: var(--text-fade);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.copy-btn:hover { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.copy-btn.copied { color: var(--good); }
|
||||||
|
/* User bubbles are right-aligned, so right-align their copy bar too. */
|
||||||
|
.msg.user .rate-bar { justify-content: flex-end; opacity: 0.4; }
|
||||||
|
.msg.user:hover .rate-bar { opacity: 0.85; }
|
||||||
|
/* Touch devices have no hover — keep the tools tappable/visible. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.rate-bar, .msg.user .rate-bar { opacity: 0.65; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Quality floor: honor reduced-motion preference. */
|
/* Quality floor: honor reduced-motion preference. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
|
|||||||
Reference in New Issue
Block a user