// ==UserScript==
// @name Zhihu Answer Copy
// @namespace https://github.com/tizee-tampermonkey-scripts/tampermonkey-zhihu-copy
// @version 1.0.0
// @description Adds a "Copy" button to each answer that copies the anwer text along with its URL and shows a check mark animation upon success, preserving link URLs and styling.
// @author tizee
// @downloadURL https://raw.githubusercontent.com/tizee-tampermonkey-scripts/tampermonkey-zhihu-copy/main/user.js
// @updateURL https://raw.githubusercontent.com/tizee-tampermonkey-scripts/tampermonkey-zhihu-copy/main/user.js
// @match https://www.zhihu.com/question/*
// @match https://www.zhihu.com/
// @grant GM_addStyle
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// Define SVG constants for the copy icon and the check mark.
const ORIGINAL_SVG = ``;
const CHECKMARK_SVG = ``;
// Inject CSS styles for the copy button and check mark animation using GM_addStyle.
GM_addStyle(`
.tm-copy-button {
display: inline-block;
cursor: pointer;
font-size: 14px;
color: #8491a5;
background: transparent;
border: none;
font-size: 14px;
margin-left: 24px;
}
.tm-copy-button span {
display: flex;
align-items: center;
}
.tm-copy-button svg {
fill: currentcolor;
width: 1.2em;
height: 1.2em;
transition: transform 0.3s ease;
}
.tm-copy-button:hover svg {
color: #758195;
}
/* Animation for the check mark */
.tm-copy-checkmark {
animation: checkmark-pop 0.5s ease-in-out;
color: #67c23a;
}
@keyframes checkmark-pop {
0% { transform: scale(0.8); opacity: 0.5;}
50% { transform: scale(1.2); opacity: 1;}
100% { transform: scale(1); opacity: 1;}
}
`);
/**
* Finds the answer container
* for the given element.
* @param {HTMLElement} element - The element to search from.
* @returns {HTMLElement|null} The answer container or null if not found.
*/
function findAnswerContainer(element) {
return element.closest('div[class="ContentItem AnswerItem"]');
}
/**
* Creates and appends a copy button to the button group element.
* The button, when clicked, copies the answer content with styling and anwer URL.
* @param {HTMLElement} groupEl - The container for answer action buttons.
*/
function addCopyButtonToGroup(groupEl) {
if (!groupEl) return;
// Retrieve the answer container for this group.
const answerContainer = findAnswerContainer(groupEl);
if (!answerContainer) return;
// Avoid adding duplicate copy buttons.
if (groupEl.querySelector('.tm-copy-button')) return;
// Create the copy button.
const copyBtn = document.createElement('button');
copyBtn.className = 'tm-copy-button';
const span = document.createElement('span');
span.innerHTML = ORIGINAL_SVG;
copyBtn.appendChild(span);
copyBtn.addEventListener('click', (e) => {
// Prevent event propagation.
e.stopPropagation();
// Extract answer text elements.
const answerElements= answerContainer.querySelector('.RichContent-inner #content');
// Process each answer text element to preserve styling and update links.
const answerContent = Array.from([answerElements]).map(el => {
const clone = el.cloneNode(true);
return {
html: clone.innerHTML,
text: clone.innerText
};
});
// Combine processed content with line breaks.
const answerHTML = answerContent.map(obj => obj.html).join('
');
const answerPlainText = answerContent.map(obj => obj.text).join('\n\n');
// Retrieve the answer URL.
let answerUrl = '';
const linkEl = answerContainer.querySelector('a[href*="/answer/"]');
if (linkEl && linkEl.href) {
answerUrl = linkEl.href;
}
// Append answer URL to the content.
const copyHTML = `${answerHTML}
Answer URL:${answerUrl}`;
const copyText = `${answerPlainText}\n\nAnswer URL: ${answerUrl}`;
// Create Blob items for HTML and plain text.
const blobHTML = new Blob([copyHTML], { type: 'text/html' });
const blobText = new Blob([copyText], { type: 'text/plain' });
// Create a ClipboardItem with both formats.
const clipboardItem = new ClipboardItem({
'text/html': blobHTML,
'text/plain': blobText,
});
// Write both formats to the clipboard.
navigator.clipboard.write([clipboardItem])
.then(() => {
// Show check mark animation on successful copy.
copyBtn.innerHTML = CHECKMARK_SVG;
copyBtn.classList.add('tm-copy-checkmark');
setTimeout(() => {
copyBtn.innerHTML = ORIGINAL_SVG;
copyBtn.classList.remove('tm-copy-checkmark');
}, 1500);
console.log('answer text copied successfully.');
})
.catch(err => console.error('Failed to copy answer text:', err));
});
// Append the button to the action group.
groupEl.appendChild(copyBtn);
}
/**
* Process all existing button group containers on the page.
*/
function processExistingGroups() {
const groups = document.querySelectorAll('div[class*="ContentItem-actions"]');
groups.forEach(groupEl => addCopyButtonToGroup(groupEl));
}
/**
* MutationObserver callback to handle dynamically added nodes.
* @param {MutationRecord[]} mutations - The list of mutations observed.
*/
function handleMutations(mutations) {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
// Gather all elements that match the group selector.
let groupEls = node.querySelectorAll('div[class*="ContentItem-actions"]');
// If the node itself is a group element, include it as well.
if (node.matches && node.matches('div[class*="ContentItem-actions"]')) {
groupEls = [node, ...groupEls];
}
groupEls.forEach(el => addCopyButtonToGroup(el));
});
});
}
// Initialize MutationObserver to monitor dynamic content changes.
const observer = new MutationObserver(handleMutations);
observer.observe(document.body, { childList: true, subtree: true });
// Process groups present on page load.
processExistingGroups();
})();