Twitter Screenshot Button

Add a screenshot button to Twitter/X post menus

当前为 2025-04-16 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter Screenshot Button
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.1
  5. // @description Add a screenshot button to Twitter/X post menus
  6. // @author You
  7. // @match https://twitter.com/*
  8. // @match https://x.com/*
  9. // @grant GM_addStyle
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/dom-to-image/2.6.0/dom-to-image.min.js
  11. // @license MIT
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // Add only necessary button styles
  18. GM_addStyle(`
  19. .screenshot-button {
  20. display: flex;
  21. align-items: center;
  22. flex-direction: row;
  23. width: 100%;
  24. padding: 12px 16px;
  25. cursor: pointer;
  26. font-size: 15px;
  27. transition-property: background-color, box-shadow;
  28. transition-duration: 0.2s;
  29. outline-style: none;
  30. box-sizing: border-box;
  31. min-height: 0px;
  32. min-width: 0px;
  33. border: 0 solid black;
  34. background-color: rgba(0, 0, 0, 0);
  35. margin: 0px;
  36. }
  37. .screenshot-button:hover {
  38. background-color: rgba(15, 20, 25, 0.1);
  39. }
  40. .screenshot-icon {
  41. margin-right: 0px; /* Keep margin 0, alignment handled by flex */
  42. width: 18.75px;
  43. height: 18.75px;
  44. /* font-weight: bold; Removed as it doesn't apply well to SVG stroke */
  45. vertical-align: text-bottom; /* Align icon better with text */
  46. }
  47. .screenshot-notification {
  48. position: fixed;
  49. top: 20px;
  50. left: 50%;
  51. transform: translateX(-50%);
  52. background-color: #1DA1F2;
  53. color: white;
  54. padding: 10px 20px;
  55. border-radius: 20px;
  56. z-index: 9999;
  57. box-shadow: 0 2px 10px rgba(0,0,0,0.2);
  58. opacity: 1;
  59. transition: opacity 0.5s ease-out;
  60. }
  61. .screenshot-notification.fade-out {
  62. opacity: 0;
  63. }
  64. `);
  65.  
  66. function findTweetMainContent(menuButton) {
  67. const article = menuButton.closest('article[role="article"]');
  68. if (!article) return null;
  69. return article;
  70. }
  71.  
  72. function takeScreenshot(menuButton) {
  73. const notification = document.createElement('div');
  74. notification.className = 'screenshot-notification';
  75. notification.innerHTML = 'Taking screenshot...';
  76. document.body.appendChild(notification);
  77.  
  78. try {
  79. const tweetContainer = findTweetMainContent(menuButton);
  80. if (!tweetContainer) {
  81. throw new Error('Could not find tweet content');
  82. }
  83.  
  84. // Save original styles
  85. const originalStyles = {
  86. background: tweetContainer.style.background,
  87. backgroundColor: tweetContainer.style.backgroundColor,
  88. margin: tweetContainer.style.margin,
  89. border: tweetContainer.style.border,
  90. borderRadius: tweetContainer.style.borderRadius
  91. };
  92.  
  93. // Optimize clarity settings
  94. const scale = window.devicePixelRatio * 2;
  95.  
  96. // --- Start Background Color Detection ---
  97. let bgColor = 'rgb(255, 255, 255)'; // Default to white
  98. try {
  99. const bodyStyle = window.getComputedStyle(document.body);
  100. bgColor = bodyStyle.backgroundColor || bgColor;
  101. // If body has no color (transparent), try a main container
  102. if (!bgColor || bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') {
  103. const mainContent = document.querySelector('main') || document.querySelector('#react-root'); // Common containers
  104. if (mainContent) {
  105. bgColor = window.getComputedStyle(mainContent).backgroundColor || 'rgb(255, 255, 255)';
  106. }
  107. }
  108. // Final fallback if detection fails
  109. if (bgColor === 'rgba(0, 0, 0, 0)' || bgColor === 'transparent') {
  110. bgColor = 'rgb(255, 255, 255)';
  111. }
  112. } catch (bgError) {
  113. console.warn("Could not detect background color, defaulting to white.", bgError);
  114. bgColor = 'rgb(255, 255, 255)';
  115. }
  116. // --- End Background Color Detection ---
  117.  
  118. const config = {
  119. height: tweetContainer.offsetHeight * scale,
  120. width: tweetContainer.offsetWidth * scale,
  121. style: {
  122. transform: `scale(${scale})`,
  123. transformOrigin: 'top left',
  124. width: `${tweetContainer.offsetWidth}px`,
  125. height: `${tweetContainer.offsetHeight}px`,
  126. margin: 0, // Ensure no extra margin affects layout
  127. border: 'none', // Remove borders for stitching
  128. borderRadius: 0 // Remove border radius for stitching
  129. },
  130. quality: 1.0
  131. };
  132. // --- Add bgcolor to config ---
  133. config.bgcolor = bgColor;
  134. // --- End add bgcolor ---
  135.  
  136. // Use dom-to-image for high-quality screenshot
  137. domtoimage.toBlob(tweetContainer, config)
  138. .then(function(blob) {
  139. // Copy to clipboard
  140. navigator.clipboard.write([
  141. new ClipboardItem({
  142. 'image/png': blob
  143. })
  144. ]).then(() => {
  145. notification.innerHTML = `
  146. <div>Screenshot copied to clipboard!</div>
  147. <button class="download-btn" style="
  148. background: white;
  149. color: #1DA1F2;
  150. border: none;
  151. padding: 5px 10px;
  152. border-radius: 15px;
  153. margin-top: 5px;
  154. cursor: pointer;
  155. ">Download</button>
  156. `;
  157. notification.style.backgroundColor = '#17BF63';
  158.  
  159. // Add download button functionality
  160. const downloadBtn = notification.querySelector('.download-btn');
  161. downloadBtn.addEventListener('click', () => {
  162. const link = document.createElement('a');
  163. link.download = `twitter-post-${Date.now()}.png`;
  164. link.href = URL.createObjectURL(blob);
  165. link.click();
  166. URL.revokeObjectURL(link.href);
  167. notification.remove();
  168. });
  169.  
  170. // 设置3秒后渐隐消失
  171. setTimeout(() => {
  172. notification.classList.add('fade-out');
  173. setTimeout(() => notification.remove(), 500);
  174. }, 1500);
  175. });
  176. })
  177. .catch(function(error) {
  178. console.error('Screenshot failed:', error);
  179. notification.textContent = 'Screenshot failed';
  180. notification.style.backgroundColor = '#E0245E';
  181. setTimeout(() => notification.remove(), 2000);
  182. });
  183. } catch (error) {
  184. console.error('Error during screenshot:', error);
  185. notification.textContent = 'Screenshot failed';
  186. notification.style.backgroundColor = '#E0245E';
  187. setTimeout(() => notification.remove(), 2000);
  188. }
  189. }
  190.  
  191. function createScreenshotIcon() {
  192. const svgNS = "http://www.w3.org/2000/svg";
  193. const svg = document.createElementNS(svgNS, "svg");
  194. svg.setAttribute("xmlns", svgNS);
  195. svg.setAttribute("viewBox", "0 0 24 24");
  196. svg.setAttribute("width", "18.75");
  197. svg.setAttribute("height", "18.75");
  198. svg.setAttribute("fill", "none"); // Use fill=none for line icons
  199. svg.setAttribute("stroke", "currentColor"); // Inherit color via stroke
  200. svg.setAttribute("stroke-width", "2");
  201. svg.setAttribute("stroke-linecap", "round");
  202. svg.setAttribute("stroke-linejoin", "round");
  203. svg.classList.add("screenshot-icon");
  204.  
  205. // Feather Icons: camera
  206. const path = document.createElementNS(svgNS, "path");
  207. path.setAttribute("d", "M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z");
  208. const circle = document.createElementNS(svgNS, "circle");
  209. circle.setAttribute("cx", "12");
  210. circle.setAttribute("cy", "13");
  211. circle.setAttribute("r", "4");
  212.  
  213. svg.appendChild(path);
  214. svg.appendChild(circle);
  215. return svg;
  216. }
  217.  
  218. function createThreadIcon() {
  219. const svgNS = "http://www.w3.org/2000/svg";
  220. const svg = document.createElementNS(svgNS, "svg");
  221. svg.setAttribute("xmlns", svgNS);
  222. svg.setAttribute("viewBox", "0 0 24 24");
  223. svg.setAttribute("width", "18.75");
  224. svg.setAttribute("height", "18.75");
  225. svg.setAttribute("fill", "none");
  226. svg.setAttribute("stroke", "currentColor");
  227. svg.setAttribute("stroke-width", "2");
  228. svg.setAttribute("stroke-linecap", "round");
  229. svg.setAttribute("stroke-linejoin", "round");
  230. svg.classList.add("screenshot-icon"); // Reuse same class for basic styling
  231.  
  232. // Simple thread icon (line connecting dots)
  233. const path1 = document.createElementNS(svgNS, "path");
  234. path1.setAttribute("d", "M6 3v12");
  235. const circle1 = document.createElementNS(svgNS, "circle");
  236. circle1.setAttribute("cx", "6");
  237. circle1.setAttribute("cy", "3");
  238. circle1.setAttribute("r", "1");
  239. const circle2 = document.createElementNS(svgNS, "circle");
  240. circle2.setAttribute("cx", "6");
  241. circle2.setAttribute("cy", "9");
  242. circle2.setAttribute("r", "1");
  243. const circle3 = document.createElementNS(svgNS, "circle");
  244. circle3.setAttribute("cx", "6");
  245. circle3.setAttribute("cy", "15");
  246. circle3.setAttribute("r", "1");
  247. // Add a parallel element to suggest thread
  248. const path2 = document.createElementNS(svgNS, "path");
  249. path2.setAttribute("d", "M18 9v12");
  250. const circle4 = document.createElementNS(svgNS, "circle");
  251. circle4.setAttribute("cx", "18");
  252. circle4.setAttribute("cy", "9");
  253. circle4.setAttribute("r", "1");
  254. const circle5 = document.createElementNS(svgNS, "circle");
  255. circle5.setAttribute("cx", "18");
  256. circle5.setAttribute("cy", "15");
  257. circle5.setAttribute("r", "1");
  258. const circle6 = document.createElementNS(svgNS, "circle");
  259. circle6.setAttribute("cx", "18");
  260. circle6.setAttribute("cy", "21");
  261. circle6.setAttribute("r", "1");
  262.  
  263.  
  264. svg.appendChild(path1);
  265. svg.appendChild(circle1);
  266. svg.appendChild(circle2);
  267. svg.appendChild(circle3);
  268. svg.appendChild(path2);
  269. svg.appendChild(circle4);
  270. svg.appendChild(circle5);
  271. svg.appendChild(circle6);
  272.  
  273.  
  274. return svg;
  275. }
  276.  
  277. async function captureThread(menuButton) {
  278. const notification = document.createElement('div');
  279. notification.className = 'screenshot-notification';
  280. notification.innerHTML = 'Capturing thread... Finding author and posts...';
  281. document.body.appendChild(notification);
  282.  
  283. try {
  284. // 1. Find original tweet and author
  285. const originalArticle = findTweetMainContent(menuButton);
  286. if (!originalArticle) {
  287. throw new Error('Could not find the starting tweet.');
  288. }
  289.  
  290. // Find author's handle (needs a robust selector, this is an example)
  291. // Twitter structure changes, this might need adjustment.
  292. const userElement = originalArticle.querySelector('[data-testid="User-Name"]'); // Try Test ID first
  293. let authorHandle = null;
  294. if (userElement) {
  295. // Find the span containing the handle like '@handle'
  296. const spans = userElement.querySelectorAll('span');
  297. for (const span of spans) {
  298. if (span.textContent.startsWith('@')) {
  299. authorHandle = span.textContent;
  300. break;
  301. }
  302. }
  303. }
  304.  
  305. // Fallback if data-testid not found or handle not in spans
  306. if (!authorHandle) {
  307. const authorLink = originalArticle.querySelector('a[href*="/status/"][dir="ltr"]');
  308. if (authorLink) {
  309. const linkParts = authorLink.href.split('/');
  310. // Usually the handle is the 3rd part like ['https:', '', 'twitter.com', 'handle', 'status', 'id']
  311. if (linkParts.length > 3) {
  312. authorHandle = '@' + linkParts[3];
  313. }
  314. }
  315. }
  316.  
  317.  
  318. if (!authorHandle) {
  319. throw new Error('Could not reliably determine the author\'s handle.');
  320. }
  321. notification.innerHTML = `Capturing thread by ${authorHandle}... Expanding replies...`;
  322. console.log(`Author Handle: ${authorHandle}`);
  323.  
  324. // 2. Find and click "Show more replies" repeatedly
  325. const conversationContainer = originalArticle.closest('div[data-testid="conversation"]'); // Find the container holding the thread
  326. let showMoreButton;
  327. const maxClicks = 15; // Limit clicks to prevent infinite loops
  328. let clicks = 0;
  329. const showMoreSelector = 'span.css-1jxf684.r-bcqeeo.r-1ttztb7.r-qvutc0.r-poiln3'; // User provided selector
  330.  
  331. while (clicks < maxClicks) {
  332. // Find the button within the conversation context if possible
  333. showMoreButton = conversationContainer
  334. ? conversationContainer.querySelector(showMoreSelector)
  335. : document.querySelector(showMoreSelector); // Fallback to document search
  336.  
  337. // Check if the button text actually indicates more replies
  338. if (showMoreButton && showMoreButton.textContent.includes('Show') && showMoreButton.closest('div[role="button"]')) { // Check text and if it's clickable
  339. console.log(`Clicking "Show more" (${clicks + 1}/${maxClicks})`);
  340. notification.innerHTML = `Capturing thread by ${authorHandle}... Expanding replies (${clicks + 1})...`;
  341. showMoreButton.closest('div[role="button"]').click(); // Click the clickable parent
  342. clicks++;
  343. // Wait for content to load - adjust delay as needed
  344. await new Promise(resolve => setTimeout(resolve, 1500)); // Wait 1.5 seconds
  345. } else {
  346. console.log("No more 'Show more' buttons found or button text doesn't match.");
  347. break; // Exit loop if no more buttons or limit reached
  348. }
  349. }
  350. if (clicks === maxClicks) {
  351. console.warn("Reached maximum 'Show more' clicks limit.");
  352. }
  353.  
  354. notification.innerHTML = `Capturing thread by ${authorHandle}... Finding all posts...`;
  355.  
  356. // 3. Filter replies by original author
  357. // Select all articles *after* the initial expansion
  358. const allArticles = Array.from(document.querySelectorAll('article[role="article"]'));
  359. const authorTweets = allArticles.filter(article => {
  360. // Re-check author handle for each potential tweet in the thread
  361. const userElement = article.querySelector('[data-testid="User-Name"]');
  362. let currentHandle = null;
  363. if (userElement) {
  364. const spans = userElement.querySelectorAll('span');
  365. for (const span of spans) {
  366. if (span.textContent.startsWith('@')) {
  367. currentHandle = span.textContent;
  368. break;
  369. }
  370. }
  371. }
  372. // Fallback check
  373. if (!currentHandle) {
  374. const authorLink = article.querySelector('a[href*="/status/"][dir="ltr"]');
  375. if (authorLink) {
  376. const linkParts = authorLink.href.split('/');
  377. if (linkParts.length > 3) {
  378. currentHandle = '@' + linkParts[3];
  379. }
  380. }
  381. }
  382. return currentHandle === authorHandle;
  383. });
  384.  
  385.  
  386. if (authorTweets.length === 0) {
  387. // If filtering removed everything, at least include the original tweet
  388. authorTweets.push(originalArticle);
  389. }
  390. // Ensure tweets are in order (usually they are by DOM order, but sort just in case)
  391. // This relies on DOM order being correct. A more robust way might involve timestamps if available.
  392. authorTweets.sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1);
  393.  
  394.  
  395. console.log(`Found ${authorTweets.length} tweets by ${authorHandle}`);
  396. notification.innerHTML = `Taking ${authorTweets.length} screenshots... (0%)`;
  397.  
  398. // 4. Screenshot each tweet individually
  399. const blobs = [];
  400. const scale = window.devicePixelRatio * 1.5; // Slightly lower scale for potentially long images
  401.  
  402. for (let i = 0; i < authorTweets.length; i++) {
  403. const tweet = authorTweets[i];
  404. const percentage = Math.round(((i + 1) / authorTweets.length) * 100);
  405. notification.innerHTML = `Taking ${authorTweets.length} screenshots... (${percentage}%)`;
  406.  
  407. // Ensure tweet is visible for screenshot (scrollIntoView might be needed sometimes)
  408. // tweet.scrollIntoView({ block: 'nearest' });
  409. // await new Promise(resolve => setTimeout(resolve, 100)); // Small delay after scroll
  410.  
  411. try {
  412. // ---> New: Check for and click internal "Show more" button within the tweet text
  413. const internalShowMoreButton = tweet.querySelector('button[data-testid="tweet-text-show-more-link"]');
  414. if (internalShowMoreButton) {
  415. console.log(`Clicking internal "Show more" for tweet ${i + 1}`);
  416. internalShowMoreButton.click();
  417. // Wait a short moment for the text to expand
  418. await new Promise(resolve => setTimeout(resolve, 500)); // 0.5 second delay
  419. }
  420. // <--- End new section
  421.  
  422. // --- Start Background Color Detection (for thread) ---
  423. let threadBgColor = 'rgb(255, 255, 255)'; // Default to white
  424. try {
  425. const bodyStyle = window.getComputedStyle(document.body);
  426. threadBgColor = bodyStyle.backgroundColor || threadBgColor;
  427. if (!threadBgColor || threadBgColor === 'rgba(0, 0, 0, 0)' || threadBgColor === 'transparent') {
  428. const mainContent = document.querySelector('main') || document.querySelector('#react-root');
  429. if (mainContent) {
  430. threadBgColor = window.getComputedStyle(mainContent).backgroundColor || 'rgb(255, 255, 255)';
  431. }
  432. }
  433. if (threadBgColor === 'rgba(0, 0, 0, 0)' || threadBgColor === 'transparent') {
  434. threadBgColor = 'rgb(255, 255, 255)';
  435. }
  436. } catch (bgError) {
  437. console.warn("Could not detect background color for thread tweet, defaulting to white.", bgError);
  438. threadBgColor = 'rgb(255, 255, 255)';
  439. }
  440. // --- End Background Color Detection ---
  441.  
  442. const config = {
  443. height: tweet.offsetHeight * scale,
  444. width: tweet.offsetWidth * scale,
  445. style: {
  446. transform: `scale(${scale})`,
  447. transformOrigin: 'top left',
  448. width: `${tweet.offsetWidth}px`,
  449. height: `${tweet.offsetHeight}px`,
  450. margin: 0, // Ensure no extra margin affects layout
  451. border: 'none', // Remove borders for stitching
  452. borderRadius: 0 // Remove border radius for stitching
  453. },
  454. quality: 0.95 // Slightly lower quality for performance/size
  455. };
  456. // --- Add bgcolor to config (for thread) ---
  457. config.bgcolor = threadBgColor;
  458. // --- End add bgcolor ---
  459.  
  460. const blob = await domtoimage.toBlob(tweet, config);
  461. blobs.push(blob);
  462. } catch (screenshotError) {
  463. console.error(`Failed to screenshot tweet ${i + 1}:`, screenshotError);
  464. // Optionally skip this tweet or stop the process
  465. notification.innerHTML = `Error screenshotting tweet ${i + 1}. Skipping.`;
  466. await new Promise(resolve => setTimeout(resolve, 1500));
  467. }
  468. }
  469.  
  470. if (blobs.length === 0) {
  471. throw new Error("No screenshots were successfully taken.");
  472. }
  473.  
  474. notification.innerHTML = `Combining ${blobs.length} screenshots...`;
  475.  
  476. // 5. Combine images using Canvas
  477. const canvas = document.createElement('canvas');
  478. const ctx = canvas.getContext('2d');
  479. let totalHeight = 0;
  480. let maxWidth = 0;
  481. const images = [];
  482.  
  483. // Convert blobs to Image objects to get dimensions
  484. for (const blob of blobs) {
  485. const img = new Image();
  486. const url = URL.createObjectURL(blob);
  487. img.src = url;
  488. await new Promise(resolve => { img.onload = resolve; }); // Wait for image data to load
  489. images.push(img);
  490. totalHeight += img.height;
  491. maxWidth = Math.max(maxWidth, img.width);
  492. // URL.revokeObjectURL(url); // Revoke later after drawing
  493. }
  494.  
  495. // Set canvas dimensions
  496. canvas.width = maxWidth;
  497. canvas.height = totalHeight;
  498.  
  499. // Draw images onto canvas
  500. let currentY = 0;
  501. for (const img of images) {
  502. ctx.drawImage(img, 0, currentY);
  503. currentY += img.height;
  504. URL.revokeObjectURL(img.src); // Revoke URL now
  505. }
  506.  
  507. // 6. Get final blob from canvas
  508. canvas.toBlob(function(finalBlob) {
  509. // 7. Handle final blob (copy/download/notification)
  510. navigator.clipboard.write([
  511. new ClipboardItem({ 'image/png': finalBlob })
  512. ]).then(() => {
  513. notification.innerHTML = `
  514. <div>Thread screenshot copied! (${images.length} posts)</div>
  515. <button class="download-btn" style="background: white; color: #1DA1F2; border: none; padding: 5px 10px; border-radius: 15px; margin-top: 5px; cursor: pointer;">Download</button>
  516. `;
  517. notification.style.backgroundColor = '#17BF63';
  518.  
  519. const downloadBtn = notification.querySelector('.download-btn');
  520. downloadBtn.addEventListener('click', () => {
  521. const link = document.createElement('a');
  522. link.download = `twitter-thread-${authorHandle.substring(1)}-${Date.now()}.png`;
  523. link.href = URL.createObjectURL(finalBlob);
  524. link.click();
  525. URL.revokeObjectURL(link.href);
  526. // Keep notification open after download click for a bit
  527. setTimeout(() => {
  528. notification.classList.add('fade-out');
  529. setTimeout(() => notification.remove(), 500);
  530. }, 1500);
  531. });
  532.  
  533. // Auto fade out after longer time for thread capture
  534. setTimeout(() => {
  535. if (!notification.classList.contains('fade-out')) { // Avoid double fade if download clicked
  536. notification.classList.add('fade-out');
  537. setTimeout(() => notification.remove(), 500);
  538. }
  539. }, 4000); // Keep notification longer
  540. }).catch(err => {
  541. console.error('Failed to copy final image:', err);
  542. notification.textContent = 'Failed to copy thread screenshot.';
  543. notification.style.backgroundColor = '#E0245E';
  544. setTimeout(() => notification.remove(), 3000);
  545. });
  546.  
  547. }, 'image/png', 0.9); // Specify type and quality
  548.  
  549. } catch (error) {
  550. console.error('Capture Thread failed:', error);
  551. notification.textContent = `Capture Thread failed: ${error.message}`;
  552. notification.style.backgroundColor = '#E0245E';
  553. setTimeout(() => {
  554. notification.classList.add('fade-out');
  555. setTimeout(() => notification.remove(), 500);
  556. }, 3000);
  557. }
  558. }
  559.  
  560. function addScreenshotButtonToMenu(menuButton) {
  561. const menu = document.querySelector('[role="menu"]');
  562. // Check if buttons already exist
  563. if (!menu || menu.querySelector('.screenshot-button') || menu.querySelector('.capture-thread-button')) return;
  564.  
  565. // --- Screenshot Button ---
  566. const screenshotButton = document.createElement('div');
  567. screenshotButton.className = 'screenshot-button'; // Keep original class for styling
  568. screenshotButton.setAttribute('role', 'menuitem');
  569. screenshotButton.setAttribute('tabindex', '0');
  570.  
  571. screenshotButton.appendChild(createScreenshotIcon());
  572.  
  573. const textScreenshot = document.createElement('span');
  574. textScreenshot.textContent = 'Screenshot';
  575. textScreenshot.style.marginLeft = '12px';
  576. textScreenshot.style.fontSize = '15px';
  577. textScreenshot.style.fontWeight = 'bold';
  578. screenshotButton.appendChild(textScreenshot);
  579.  
  580. screenshotButton.addEventListener('click', (event) => {
  581. event.stopPropagation(); // Prevent menu closing immediately if something goes wrong
  582. takeScreenshot(menuButton);
  583. // Attempt to close the menu after action
  584. setTimeout(() => {
  585. const closeButton = document.querySelector('[data-testid="Dropdown"] [aria-label="Close"]'); // More specific selector
  586. if (closeButton) closeButton.click();
  587. // Fallback for menu itself if close button not found reliably
  588. else if (menu && menu.style.display !== 'none') {
  589. // Heuristic: Clicking away might close it, or find a parent dismiss layer
  590. // This part is tricky due to varying menu implementations
  591. }
  592. }, 100); // Small delay
  593. });
  594.  
  595. menu.insertBefore(screenshotButton, menu.firstChild); // Insert at the top
  596.  
  597.  
  598. // --- Capture Thread Button ---
  599. const captureThreadButton = document.createElement('div');
  600. // Use screenshot-button class for base styles, add specific class if needed
  601. captureThreadButton.className = 'screenshot-button capture-thread-button';
  602. captureThreadButton.setAttribute('role', 'menuitem');
  603. captureThreadButton.setAttribute('tabindex', '0');
  604.  
  605. captureThreadButton.appendChild(createThreadIcon());
  606.  
  607. const textThread = document.createElement('span');
  608. textThread.textContent = 'Capture Thread';
  609. textThread.style.marginLeft = '12px';
  610. textThread.style.fontSize = '15px';
  611. textThread.style.fontWeight = 'bold';
  612. captureThreadButton.appendChild(textThread);
  613.  
  614. captureThreadButton.addEventListener('click', (event) => {
  615. event.stopPropagation();
  616. captureThread(menuButton);
  617. // Attempt to close the menu after action
  618. setTimeout(() => {
  619. const closeButton = document.querySelector('[data-testid="Dropdown"] [aria-label="Close"]');
  620. if (closeButton) closeButton.click();
  621. else if (menu && menu.style.display !== 'none') {
  622. // Fallback...
  623. }
  624. }, 100);
  625. });
  626.  
  627. // Insert Capture Thread button below the Screenshot button
  628. if (screenshotButton.nextSibling) {
  629. menu.insertBefore(captureThreadButton, screenshotButton.nextSibling);
  630. } else {
  631. menu.appendChild(captureThreadButton);
  632. }
  633. }
  634.  
  635. function addScreenshotButtons() {
  636. const observer = new MutationObserver((mutations) => {
  637. mutations.forEach((mutation) => {
  638. if (mutation.addedNodes.length) {
  639. mutation.addedNodes.forEach((node) => {
  640. // Check if the added node itself is a menu or contains one
  641. if (node.nodeType === 1) { // Check if it's an element node
  642. const menu = node.matches('[role="menu"]') ? node : node.querySelector('[role="menu"]');
  643. if (menu) {
  644. // Find the button that triggered this menu
  645. const menuButton = document.querySelector('[aria-haspopup="menu"][aria-expanded="true"]');
  646. // IMPORTANT CHECK: Ensure the menu was triggered by the "More" button (three dots)
  647. // within an article, typically identified by data-testid="caret".
  648. if (menuButton && menuButton.closest('article[role="article"]') && menuButton.getAttribute('data-testid') === 'caret') {
  649. console.log("Detected 'More' menu, adding buttons.");
  650. addScreenshotButtonToMenu(menuButton);
  651. } else {
  652. // Optional: Log why buttons weren't added
  653. // console.log("Detected menu, but not triggered by the target 'More' button or not within an article.");
  654. }
  655. }
  656. }
  657. });
  658. }
  659. });
  660. });
  661.  
  662. observer.observe(document.body, {
  663. childList: true,
  664. subtree: true
  665. });
  666. }
  667.  
  668. addScreenshotButtons();
  669. })();