@@ -476,7 +476,37 @@ export function writeHtmlReport(
476476 border-color: rgba(82, 212, 166, 0.6);
477477 }
478478
479+ .reject-btn {
480+ border: 1px solid rgba(251, 146, 60, 0.55);
481+ background: rgba(251, 146, 60, 0.15);
482+ color: #9a3412;
483+ padding: 4px 10px;
484+ border-radius: 999px;
485+ font-size: 11px;
486+ font-weight: 600;
487+ cursor: pointer;
488+ text-transform: uppercase;
489+ letter-spacing: 0.5px;
490+ transition: all 0.15s ease;
491+ }
492+
493+ .reject-btn:hover {
494+ background: rgba(251, 146, 60, 0.28);
495+ border-color: rgba(251, 146, 60, 0.75);
496+ }
497+
498+ details.group.rejected .reject-btn {
499+ background: rgba(251, 146, 60, 0.32);
500+ border-color: rgba(194, 65, 12, 0.7);
501+ color: #7c2d12;
502+ }
503+
504+ details.group.rejected {
505+ border-color: rgba(251, 146, 60, 0.5);
506+ }
507+
479508 .word-btn,
509+ .copy-path-btn,
480510 .word-overlay-btn {
481511 border: 1px solid rgba(15, 25, 45, 0.18);
482512 background: rgba(15, 25, 45, 0.06);
@@ -491,6 +521,7 @@ export function writeHtmlReport(
491521 }
492522
493523 .word-btn:hover:not(:disabled),
524+ .copy-path-btn:hover:not(:disabled),
494525 .word-overlay-btn:hover:not(:disabled) {
495526 background: rgba(15, 25, 45, 0.12);
496527 border-color: rgba(15, 25, 45, 0.28);
@@ -787,6 +818,7 @@ export function writeHtmlReport(
787818 </div>
788819 <div class="actions">
789820 <button class="secondary" id="toggle-layout">Show side-by-side</button>
821+ <button class="secondary" id="export-rejected" hidden>Export rejected docs list</button>
790822 <button class="secondary" id="toggle-lens">Disable magnifier</button>
791823 <button class="secondary" id="toggle-unchanged">Hide unchanged</button>
792824 <button class="secondary" id="toggle-approved">Show approved</button>
@@ -811,6 +843,7 @@ export function writeHtmlReport(
811843 const zoomLens = document.getElementById('zoom-lens');
812844 const toggleLensButton = document.getElementById('toggle-lens');
813845 const toggleLayoutButton = document.getElementById('toggle-layout');
846+ const exportRejectedButton = document.getElementById('export-rejected');
814847
815848 const diffs = report.results.filter((item) => !item.passed);
816849 const showAll = ${ JSON . stringify ( showAll ) } ;
@@ -819,6 +852,8 @@ export function writeHtmlReport(
819852
820853 // Approval state (session only, resets on refresh)
821854 const approved = new Set();
855+ const rejectedGroups = new Set();
856+ const buttonTimers = new WeakMap();
822857
823858 const resultsFolderName = (report.resultsFolder || '').replace(/\\\\/g, '/');
824859 const resultsPrefix = resultsFolderName ? resultsFolderName + '/' : '';
@@ -854,6 +889,79 @@ export function writeHtmlReport(
854889 return parsed;
855890 }
856891
892+ function setTemporaryButtonLabel(button, text, ms) {
893+ if (!(button instanceof HTMLButtonElement)) return;
894+ const defaultText = button.dataset.defaultText || button.textContent || '';
895+ const existingTimer = buttonTimers.get(button);
896+ if (existingTimer) {
897+ clearTimeout(existingTimer);
898+ }
899+ button.textContent = text;
900+ const timer = setTimeout(() => {
901+ button.textContent = defaultText;
902+ buttonTimers.delete(button);
903+ }, ms);
904+ buttonTimers.set(button, timer);
905+ }
906+
907+ /** @deprecated fallback for browsers without navigator.clipboard — remove when no longer needed */
908+ function copyTextWithFallback(value) {
909+ const textArea = document.createElement('textarea');
910+ textArea.value = value;
911+ textArea.setAttribute('readonly', 'true');
912+ textArea.style.position = 'fixed';
913+ textArea.style.opacity = '0';
914+ textArea.style.left = '-9999px';
915+ document.body.appendChild(textArea);
916+ textArea.focus();
917+ textArea.select();
918+ let copied = false;
919+ try {
920+ copied = typeof document.execCommand === 'function' && document.execCommand('copy');
921+ } catch (_error) {
922+ copied = false;
923+ }
924+ textArea.remove();
925+ return copied;
926+ }
927+
928+ async function copyToClipboard(value) {
929+ if (!value) return false;
930+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
931+ try {
932+ await navigator.clipboard.writeText(value);
933+ return true;
934+ } catch (_error) {
935+ return copyTextWithFallback(value);
936+ }
937+ }
938+ return copyTextWithFallback(value);
939+ }
940+
941+ function updateRejectedExportButton() {
942+ if (!exportRejectedButton) return;
943+ exportRejectedButton.hidden = rejectedGroups.size === 0;
944+ }
945+
946+ function exportRejectedDocsList() {
947+ if (rejectedGroups.size === 0) return;
948+ const rejectedDocs = Array.from(rejectedGroups)
949+ .sort((a, b) => a.localeCompare(b))
950+ .map((groupName) => (groupName === '.' ? '(root)' : groupName));
951+ const reportName = resultsFolderName || 'report';
952+ const fileName = reportName + '-rejected-docs.txt';
953+ const content = rejectedDocs.join('\\n') + '\\n';
954+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
955+ const url = URL.createObjectURL(blob);
956+ const link = document.createElement('a');
957+ link.href = url;
958+ link.download = fileName;
959+ document.body.appendChild(link);
960+ link.click();
961+ link.remove();
962+ setTimeout(() => URL.revokeObjectURL(url), 0);
963+ }
964+
857965 function createWordOverlayController(sourceDoc) {
858966 const sourceLocalPath = typeof sourceDoc?.localPath === 'string' ? sourceDoc.localPath : '';
859967 const sourceRelativePath = typeof sourceDoc?.relativePath === 'string' ? sourceDoc.relativePath : '';
@@ -1360,6 +1468,7 @@ export function writeHtmlReport(
13601468 groupEntries.forEach(([dir, items]) => {
13611469 const details = document.createElement('details');
13621470 details.className = 'group';
1471+ details.dataset.group = dir;
13631472 details.open = false;
13641473
13651474 const summary = document.createElement('summary');
@@ -1413,6 +1522,22 @@ export function writeHtmlReport(
14131522
14141523 actionWrap.appendChild(wordBtn);
14151524
1525+ const copyPathBtn = document.createElement('button');
1526+ copyPathBtn.type = 'button';
1527+ copyPathBtn.className = 'copy-path-btn';
1528+ copyPathBtn.textContent = 'Copy doc path';
1529+ copyPathBtn.dataset.defaultText = 'Copy doc path';
1530+ const copyDocPath =
1531+ (sourceDoc && sourceDoc.originalLocalPath) || (sourceDoc && sourceDoc.localPath) || '';
1532+ if (copyDocPath) {
1533+ copyPathBtn.dataset.docPath = copyDocPath;
1534+ copyPathBtn.title = 'Copy full local path to clipboard';
1535+ } else {
1536+ copyPathBtn.disabled = true;
1537+ copyPathBtn.title = 'Doc path not available locally.';
1538+ }
1539+ actionWrap.appendChild(copyPathBtn);
1540+
14161541 wordOverlayController = createWordOverlayController(sourceDoc);
14171542 actionWrap.appendChild(wordOverlayController.button);
14181543 }
@@ -1424,6 +1549,13 @@ export function writeHtmlReport(
14241549 approveBtn.dataset.group = dir;
14251550 actionWrap.appendChild(approveBtn);
14261551
1552+ const rejectBtn = document.createElement('button');
1553+ rejectBtn.type = 'button';
1554+ rejectBtn.className = 'reject-btn';
1555+ rejectBtn.textContent = 'Reject doc';
1556+ rejectBtn.dataset.groupReject = dir;
1557+ actionWrap.appendChild(rejectBtn);
1558+
14271559 summary.appendChild(titleWrap);
14281560 summary.appendChild(count);
14291561 summary.appendChild(actionWrap);
@@ -1758,6 +1890,19 @@ export function writeHtmlReport(
17581890 }
17591891
17601892 groupsContainer.addEventListener('click', (event) => {
1893+ const copyPathBtn = event.target.closest('.copy-path-btn');
1894+ if (copyPathBtn) {
1895+ event.preventDefault();
1896+ event.stopPropagation();
1897+ if (copyPathBtn.disabled) return;
1898+ const docPath = copyPathBtn.dataset.docPath;
1899+ if (!docPath) return;
1900+ copyToClipboard(docPath).then((didCopy) => {
1901+ setTemporaryButtonLabel(copyPathBtn, didCopy ? 'Copied' : 'Copy failed', didCopy ? 1300 : 1800);
1902+ });
1903+ return;
1904+ }
1905+
17611906 const openWordBtn = event.target.closest('.word-btn');
17621907 if (openWordBtn) {
17631908 event.preventDefault();
@@ -1770,6 +1915,54 @@ export function writeHtmlReport(
17701915 return;
17711916 }
17721917
1918+ const rejectBtn = event.target.closest('.reject-btn');
1919+ if (rejectBtn) {
1920+ event.preventDefault();
1921+ event.stopPropagation();
1922+
1923+ const group = rejectBtn.closest('details.group');
1924+ if (!group) return;
1925+ const groupName = rejectBtn.dataset.groupReject;
1926+ if (!groupName) return;
1927+ const groupKey = 'group:' + groupName;
1928+ const isRejected = group.classList.contains('rejected');
1929+ const approveBtn = group.querySelector('.approve-btn[data-group]');
1930+
1931+ if (isRejected) {
1932+ group.classList.remove('rejected');
1933+ rejectBtn.textContent = 'Reject doc';
1934+ rejectedGroups.delete(groupName);
1935+ } else {
1936+ const cards = group.querySelectorAll('.diff-card');
1937+ cards.forEach((card) => {
1938+ if (!card.classList.contains('approved')) return;
1939+ card.classList.remove('approved');
1940+ const cardBtn = card.querySelector('.approve-btn[data-card]');
1941+ if (cardBtn) {
1942+ cardBtn.textContent = 'Approve';
1943+ }
1944+ const cardKey = cardBtn ? 'card:' + cardBtn.dataset.card : '';
1945+ if (cardKey) {
1946+ approved.delete(cardKey);
1947+ }
1948+ });
1949+
1950+ group.classList.remove('approved');
1951+ if (approveBtn) {
1952+ approveBtn.textContent = 'Approve doc';
1953+ }
1954+ approved.delete(groupKey);
1955+
1956+ group.classList.add('rejected');
1957+ rejectBtn.textContent = 'Unreject doc';
1958+ rejectedGroups.add(groupName);
1959+ }
1960+
1961+ updateRejectedExportButton();
1962+ updateGroupCounts();
1963+ return;
1964+ }
1965+
17731966 const btn = event.target.closest('.approve-btn');
17741967 if (!btn) return;
17751968
@@ -1783,6 +1976,7 @@ export function writeHtmlReport(
17831976
17841977 const groupKey = 'group:' + btn.dataset.group;
17851978 const isApproved = group.classList.contains('approved');
1979+ const rejectBtn = group.querySelector('.reject-btn[data-group-reject]');
17861980
17871981 // Approve/unapprove all cards inside this group
17881982 const cards = group.querySelectorAll('.diff-card');
@@ -1805,6 +1999,17 @@ export function writeHtmlReport(
18051999 btn.textContent = 'Approve doc';
18062000 approved.delete(groupKey);
18072001 } else {
2002+ if (group.classList.contains('rejected')) {
2003+ group.classList.remove('rejected');
2004+ const groupName = btn.dataset.group;
2005+ if (groupName) {
2006+ rejectedGroups.delete(groupName);
2007+ }
2008+ if (rejectBtn) {
2009+ rejectBtn.textContent = 'Reject doc';
2010+ }
2011+ updateRejectedExportButton();
2012+ }
18082013 group.classList.add('approved');
18092014 btn.textContent = 'Unapprove doc';
18102015 approved.add(groupKey);
@@ -1854,6 +2059,15 @@ export function writeHtmlReport(
18542059 });
18552060 }
18562061
2062+ if (exportRejectedButton) {
2063+ exportRejectedButton.dataset.defaultText = 'Export rejected docs list';
2064+ exportRejectedButton.addEventListener('click', () => {
2065+ exportRejectedDocsList();
2066+ setTemporaryButtonLabel(exportRejectedButton, 'Exported', 1400);
2067+ });
2068+ }
2069+ updateRejectedExportButton();
2070+
18572071 // Initial count update
18582072 updateGroupCounts();
18592073 </script>
0 commit comments