// ==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(); })();