T3Chat Table Copier

Add "Copy Table" buttons to tables in T3Chat to Office tools

  1. // ==UserScript==
  2. // @name T3Chat Table Copier
  3. // @namespace wearifulpoet.com
  4. // @version 0.1.1
  5. // @description Add "Copy Table" buttons to tables in T3Chat to Office tools
  6. // @match https://t3.chat/*
  7. // @run-at document-idle
  8. // @grant none
  9. // @license MIT
  10. // ==/UserScript==
  11.  
  12. (() => {
  13. const TABLE_SELECTOR = 'table';
  14. const CONTENT_SELECTOR = '[role="article"], .prose, [data-testid="message-content"]';
  15. const processedTables = new WeakSet();
  16.  
  17. const createCopyButton = () => {
  18. const button = document.createElement('button');
  19. button.innerHTML = `
  20. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  21. <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
  22. <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
  23. </svg>
  24. Copy Table
  25. `;
  26. button.className = `
  27. inline-flex items-center gap-2 px-3 py-1.5
  28. text-xs font-medium text-muted-foreground
  29. bg-muted hover:bg-muted/80
  30. border border-border rounded-md
  31. transition-colors duration-200
  32. focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2
  33. `.replace(/\s+/g, ' ').trim();
  34. button.setAttribute('data-table-copy-button', '1');
  35. button.setAttribute('aria-label', 'Copy table to clipboard');
  36. button.setAttribute('title', 'Copy table with formatting for pasting into documents');
  37. return button;
  38. };
  39.  
  40. const createButtonContainer = () => {
  41. const container = document.createElement('div');
  42. container.className = 'flex justify-end mt-2 mb-2';
  43. container.setAttribute('data-table-button-container', '1');
  44. return container;
  45. };
  46.  
  47. const processTableForCopy = (table) => {
  48. const clone = table.cloneNode(true);
  49. clone.querySelectorAll('[data-table-copy-button],[data-table-button-container]').forEach(el => el.remove());
  50. clone.querySelectorAll('*').forEach(el => {
  51. if (el.tagName.match(/^(TABLE|THEAD|TBODY|TFOOT|TR|TH|TD|CAPTION|COLGROUP|COL)$/)) {
  52. el.removeAttribute('class');
  53. el.removeAttribute('style');
  54. el.removeAttribute('data-testid');
  55. Array.from(el.attributes).forEach(attr => {
  56. if (!['colspan', 'rowspan', 'scope', 'headers'].includes(attr.name.toLowerCase())) el.removeAttribute(attr.name);
  57. });
  58. } else {
  59. const text = (el.textContent || '').trim();
  60. if (text) {
  61. el.replaceWith(document.createTextNode(text));
  62. } else {
  63. el.remove();
  64. }
  65. }
  66. });
  67.  
  68. const cleanTable = document.createElement('table');
  69. cleanTable.border = '1';
  70. cleanTable.cellPadding = '4';
  71. cleanTable.cellSpacing = '0';
  72. Object.assign(cleanTable.style, { borderCollapse: 'collapse', width: '100%' });
  73.  
  74. let hasHeader = false;
  75. clone.querySelectorAll('tr').forEach((row, rowIndex) => {
  76. const newRow = document.createElement('tr');
  77. row.querySelectorAll('th,td').forEach(cell => {
  78. const isHeader = cell.tagName === 'TH' || (rowIndex === 0 && !hasHeader);
  79. const newCell = document.createElement(isHeader ? 'th' : 'td');
  80. if (isHeader) {
  81. hasHeader = true;
  82. Object.assign(newCell.style, { fontWeight: 'bold', backgroundColor: '#f0f0f0' });
  83. }
  84. Object.assign(newCell.style, {
  85. border: '1px solid #ccc',
  86. padding: '8px',
  87. textAlign: 'left',
  88. verticalAlign: 'top'
  89. });
  90. if (cell.hasAttribute('colspan')) newCell.setAttribute('colspan', cell.getAttribute('colspan'));
  91. if (cell.hasAttribute('rowspan')) newCell.setAttribute('rowspan', cell.getAttribute('rowspan'));
  92. newCell.textContent = (cell.textContent || '').trim();
  93. newRow.appendChild(newCell);
  94. });
  95. cleanTable.appendChild(newRow);
  96. });
  97. return cleanTable;
  98. };
  99.  
  100. const generateTSV = (table) =>
  101. Array.from(table.querySelectorAll('tr'))
  102. .map(row =>
  103. Array.from(row.querySelectorAll('th,td'))
  104. .map(cell => (cell.textContent || '').trim().replace(/[\t\n\r]+/g, ' ').replace(/\s+/g, ' '))
  105. .join('\t')
  106. )
  107. .join('\n');
  108.  
  109. const copyTableToClipboard = async (table) => {
  110. const processed = processTableForCopy(table);
  111. const html = processed.outerHTML;
  112. const tsv = generateTSV(processed);
  113.  
  114. try {
  115. if (navigator.clipboard?.write) {
  116. const fullHTML = `
  117. <html>
  118. <head>
  119. <meta charset="utf-8">
  120. <style>
  121. table{border-collapse:collapse;width:100%}
  122. th,td{border:1px solid #ccc;padding:8px;text-align:left;vertical-align:top}
  123. th{font-weight:bold;background:#f0f0f0}
  124. </style>
  125. </head>
  126. <body>${html}</body>
  127. </html>
  128. `.trim();
  129. await navigator.clipboard.write([new ClipboardItem({
  130. 'text/html': new Blob([fullHTML], { type: 'text/html' }),
  131. 'text/plain': new Blob([tsv], { type: 'text/plain' })
  132. })]);
  133. } else if (navigator.clipboard?.writeText) {
  134. await navigator.clipboard.writeText(tsv);
  135. } else {
  136. const textarea = document.createElement('textarea');
  137. textarea.value = tsv;
  138. document.body.appendChild(textarea);
  139. textarea.select();
  140. document.execCommand('copy');
  141. document.body.removeChild(textarea);
  142. }
  143. showCopyFeedback(table);
  144. } catch {
  145. showCopyError(table);
  146. }
  147. };
  148.  
  149. const showCopyFeedback = (table) => {
  150. const button = table.parentElement?.querySelector('[data-table-copy-button]');
  151. if (!button) return;
  152. const original = button.innerHTML;
  153. button.innerHTML = `
  154. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  155. <polyline points="20 6 9 17 4 12"></polyline>
  156. </svg>
  157. Copied!
  158. `;
  159. button.style.color = 'rgb(34,197,94)';
  160. setTimeout(() => {
  161. button.innerHTML = original;
  162. button.style.color = '';
  163. }, 2000);
  164. };
  165.  
  166. const showCopyError = (table) => {
  167. const button = table.parentElement?.querySelector('[data-table-copy-button]');
  168. if (!button) return;
  169. const original = button.innerHTML;
  170. button.innerHTML = `
  171. <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  172. <circle cx="12" cy="12" r="10"></circle>
  173. <line x1="15" y1="9" x2="9" y2="15"></line>
  174. <line x1="9" y1="9" x2="15" y2="15"></line>
  175. </svg>
  176. Error
  177. `;
  178. button.style.color = 'rgb(239,68,68)';
  179. setTimeout(() => {
  180. button.innerHTML = original;
  181. button.style.color = '';
  182. }, 2000);
  183. };
  184.  
  185. const addCopyButton = (table) => {
  186. if (processedTables.has(table) || table.rows.length < 2) return;
  187. const button = createCopyButton();
  188. const container = createButtonContainer();
  189. button.addEventListener('click', e => {
  190. e.preventDefault();
  191. e.stopPropagation();
  192. copyTableToClipboard(table);
  193. });
  194. container.appendChild(button);
  195. table.parentNode.insertBefore(container, table.nextSibling);
  196. processedTables.add(table);
  197. };
  198.  
  199. const scanTables = () => {
  200. document.querySelectorAll(CONTENT_SELECTOR).forEach(area =>
  201. area.querySelectorAll(TABLE_SELECTOR).forEach(addCopyButton)
  202. );
  203. };
  204.  
  205. const observer = new MutationObserver(mutations => {
  206. const added = mutations.some(m =>
  207. Array.from(m.addedNodes).some(node =>
  208. node.nodeType === 1 && (node.tagName === 'TABLE' || node.querySelector?.(TABLE_SELECTOR))
  209. )
  210. );
  211. if (added) setTimeout(scanTables, 100);
  212. });
  213.  
  214. const init = () => {
  215. scanTables();
  216. observer.observe(document.documentElement, { childList: true, subtree: true });
  217. };
  218.  
  219. if (document.readyState === 'loading') {
  220. document.addEventListener('DOMContentLoaded', init);
  221. } else {
  222. init();
  223. }
  224. })();