diff options
| author | Tadas Vilkeliskis <vilkeliskis.t@gmail.com> | 2026-01-22 09:28:00 -0500 |
|---|---|---|
| committer | Tadas Vilkeliskis <vilkeliskis.t@gmail.com> | 2026-01-22 09:28:00 -0500 |
| commit | 2561e1c19d31b9565c9ab31f21bb48a53a2708b8 (patch) | |
| tree | fbce970a2f38cf9fc951e28a7e839ede8254cdf3 | |
add initial redactor implementation
| -rw-r--r-- | redact.html | 714 |
1 files changed, 714 insertions, 0 deletions
diff --git a/redact.html b/redact.html new file mode 100644 index 0000000..aef0697 --- /dev/null +++ b/redact.html @@ -0,0 +1,714 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Just Redact - PDF redaction tool</title> + <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf_viewer.min.css"> + <style> + body { + font-family: sans-serif; + background: #f0f0f0; + margin: 0; + padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + } + + #controls { + background: white; + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + margin-bottom: 20px; + display: flex; + gap: 15px; + align-items: center; + position: sticky; + top: 20px; + z-index: 1000; + } + + .tool-group { + display: flex; + background: #e9ecef; + border-radius: 4px; + padding: 2px; + } + + .tool-group label { + padding: 6px 12px; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + user-select: none; + } + + .tool-group input[type="radio"] { + display: none; + } + + .tool-group input[type="radio"]:checked + label { + background: white; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + font-weight: bold; + color: #007bff; + } + + button { + padding: 8px 16px; + background: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + } + + button:hover { + background: #0056b3; + } + + button:disabled { + background: #ccc; + cursor: not-allowed; + } + + #file-input { + display: none; + } + + .custom-file-upload { + border: 1px solid #ccc; + display: inline-block; + padding: 6px 12px; + cursor: pointer; + border-radius: 4px; + background: #f8f9fa; + } + + #container { + display: flex; + flex-direction: column; + gap: 20px; + } + + /* Mode: Select (disable overlay pointer events so text is selectable) */ + .mode-select .page-overlay { + pointer-events: none; + } + /* Mode: Draw (enable overlay pointer events) */ + .mode-draw .page-overlay { + pointer-events: auto; + } + + .page-wrapper { + position: relative; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + background: white; + } + + .page-overlay { + position: absolute; + top: 0; + left: 0; + z-index: 20; /* Above TextLayer (usually 1 or 2) */ + cursor: crosshair; + } + + /* pdf.js TextLayer styles are imported, but we ensure it fits */ + .textLayer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + line-height: 1.0; + z-index: 5; + } + + .redaction-box { + position: absolute; + background-color: rgba(0, 0, 0, 0.7); + border: 1px solid red; + cursor: move; /* Changed to move for dragging */ + box-sizing: border-box; + z-index: 30; /* Above everything */ + pointer-events: auto !important; /* Always interactive */ + } + + .redaction-box:hover { + background-color: rgba(255, 0, 0, 0.5); + border: 1px solid #ff0000; + } + + /* Resize Handles */ + .resize-handle { + position: absolute; + width: 10px; + height: 10px; + background: white; + border: 1px solid black; + z-index: 31; + } + .handle-nw { top: -5px; left: -5px; cursor: nw-resize; } + .handle-ne { top: -5px; right: -5px; cursor: ne-resize; } + .handle-sw { bottom: -5px; left: -5px; cursor: sw-resize; } + .handle-se { bottom: -5px; right: -5px; cursor: se-resize; } + + /* Delete Button */ + .delete-btn { + position: absolute; + top: -12px; + right: -12px; + width: 20px; + height: 20px; + background: red; + color: white; + border-radius: 50%; + text-align: center; + line-height: 18px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + border: 1px solid white; + display: none; + z-index: 32; + } + .redaction-box:hover .delete-btn { + display: block; + } + + /* Floating Redact Button */ + #redact-selection-btn { + position: fixed; + display: none; + z-index: 2000; + padding: 8px 12px; + background: #333; + color: white; + border-radius: 4px; + font-size: 13px; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + transform: translate(-50%, -100%); /* Center above cursor */ + pointer-events: auto; + } + #redact-selection-btn:after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + margin-left: -6px; + border-width: 6px; + border-style: solid; + border-color: #333 transparent transparent transparent; + } + + /* Loading spinner */ + .loader { + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + display: none; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + </style> +</head> +<body> + + <div id="controls"> + <label for="file-input" class="custom-file-upload"> + 📂 Open PDF + </label> + <input id="file-input" type="file" accept="application/pdf" /> + + <div class="tool-group"> + <input type="radio" id="mode-draw" name="mode" value="draw" checked> + <label for="mode-draw">✏️ Draw</label> + + <input type="radio" id="mode-select" name="mode" value="select"> + <label for="mode-select">📝 Select Text</label> + </div> + + <span id="page-count"></span> + + <button id="export-btn" disabled>🔒 Export Redacted PDF</button> + <div class="loader" id="loader"></div> + </div> + + <button id="redact-selection-btn">Redact Selection</button> + + <div id="container" class="mode-draw"></div> + + <script> + // Set up PDF.js worker + pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; + + const fileInput = document.getElementById('file-input'); + const container = document.getElementById('container'); + const exportBtn = document.getElementById('export-btn'); + const loader = document.getElementById('loader'); + const pageCountSpan = document.getElementById('page-count'); + const redactSelectionBtn = document.getElementById('redact-selection-btn'); + const modeRadios = document.querySelectorAll('input[name="mode"]'); + + let pdfDoc = null; + let redactionsMap = new Map(); // pageIndex -> [div, div, ...] + let scale = 1.5; // Viewport scale + + // Mode Switching + modeRadios.forEach(radio => { + radio.addEventListener('change', (e) => { + if (e.target.checked) { + container.className = `mode-${e.target.value}`; + // Clear any selection/buttons when switching modes + window.getSelection().removeAllRanges(); + redactSelectionBtn.style.display = 'none'; + } + }); + }); + + // Text Selection Listener + document.addEventListener('selectionchange', () => { + const selection = window.getSelection(); + if (selection.isCollapsed || container.classList.contains('mode-draw')) { + redactSelectionBtn.style.display = 'none'; + return; + } + + // Check if selection is inside a PDF page + const range = selection.getRangeAt(0); + const containerRect = container.getBoundingClientRect(); + + // Just use the last rect to position the button + const rects = range.getClientRects(); + if (rects.length > 0) { + const lastRect = rects[rects.length - 1]; + redactSelectionBtn.style.left = `${lastRect.left + (lastRect.width / 2)}px`; + redactSelectionBtn.style.top = `${lastRect.top - 10}px`; + redactSelectionBtn.style.display = 'block'; + } + }); + + // "Redact Selection" Click Handler + redactSelectionBtn.addEventListener('click', () => { + const selection = window.getSelection(); + if (selection.isCollapsed) return; + + const range = selection.getRangeAt(0); + const rects = range.getClientRects(); + + // Find which page(s) these rects belong to + // We iterate all rects because a selection might cross pages (though pdf.js usually handles pages separately) + for (let i = 0; i < rects.length; i++) { + const clientRect = rects[i]; + + // Hit test against page wrappers + const wrappers = document.querySelectorAll('.page-wrapper'); + for (let wrapper of wrappers) { + const wrapperRect = wrapper.getBoundingClientRect(); + + // Check intersection + if (clientRect.top >= wrapperRect.top && + clientRect.bottom <= wrapperRect.bottom && + clientRect.left >= wrapperRect.left && + clientRect.right <= wrapperRect.right) { + + // It belongs to this page + const pageNum = parseInt(wrapper.dataset.pageIndex); + const overlay = wrapper.querySelector('.page-overlay'); + + // Convert to overlay relative coords + const x = clientRect.left - wrapperRect.left; + const y = clientRect.top - wrapperRect.top; + const w = clientRect.width; + const h = clientRect.height; + + createRedactionBox(overlay, pageNum, x, y, w, h); + } + } + } + + window.getSelection().removeAllRanges(); + redactSelectionBtn.style.display = 'none'; + }); + + function createRedactionBox(overlay, pageNum, x, y, w, h) { + const box = document.createElement('div'); + box.className = 'redaction-box'; + box.style.left = `${x}px`; + box.style.top = `${y}px`; + box.style.width = `${w}px`; + box.style.height = `${h}px`; + overlay.appendChild(box); + + makeInteractive(box, overlay, pageNum); + + if (!redactionsMap.has(pageNum)) { + redactionsMap.set(pageNum, []); + } + redactionsMap.get(pageNum).push(box); + } + + function setupDrawing(overlay, pageNum) { + let isDrawing = false; + let startX, startY; + let currentBox = null; + + overlay.addEventListener('mousedown', (e) => { + // Don't start drawing if clicking on an existing box or its controls + if (e.target.closest('.redaction-box')) return; + + isDrawing = true; + const rect = overlay.getBoundingClientRect(); + startX = e.clientX - rect.left; + startY = e.clientY - rect.top; + + currentBox = document.createElement('div'); + currentBox.className = 'redaction-box'; + currentBox.style.left = `${startX}px`; + currentBox.style.top = `${startY}px`; + currentBox.style.width = '0px'; + currentBox.style.height = '0px'; + overlay.appendChild(currentBox); + }); + + overlay.addEventListener('mousemove', (e) => { + if (!isDrawing) return; + const rect = overlay.getBoundingClientRect(); + const currentX = e.clientX - rect.left; + const currentY = e.clientY - rect.top; + + const width = currentX - startX; + const height = currentY - startY; + + currentBox.style.width = `${Math.abs(width)}px`; + currentBox.style.height = `${Math.abs(height)}px`; + currentBox.style.left = `${width < 0 ? currentX : startX}px`; + currentBox.style.top = `${height < 0 ? currentY : startY}px`; + }); + + overlay.addEventListener('mouseup', (e) => { + if (!isDrawing) return; + isDrawing = false; + + // Finalize box + const w = parseFloat(currentBox.style.width); + const h = parseFloat(currentBox.style.height); + + // Ignore tiny clicks + if (w < 5 && h < 5) { + currentBox.remove(); + return; + } + + // Make interactive (drag/resize/delete) + makeInteractive(currentBox, overlay, pageNum); + + // Store in state + if (!redactionsMap.has(pageNum)) { + redactionsMap.set(pageNum, []); + } + redactionsMap.get(pageNum).push(currentBox); + + currentBox = null; + }); + } + + fileInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Reset + container.innerHTML = ''; + redactionsMap.clear(); + exportBtn.disabled = true; + pageCountSpan.innerText = 'Loading...'; + + const fileReader = new FileReader(); + fileReader.onload = async function() { + const typedarray = new Uint8Array(this.result); + try { + pdfDoc = await pdfjsLib.getDocument(typedarray).promise; + pageCountSpan.innerText = `${pdfDoc.numPages} Pages`; + renderPages(); + exportBtn.disabled = false; + } catch (error) { + console.error('Error loading PDF:', error); + alert('Error loading PDF. Please check the console.'); + } + }; + fileReader.readAsArrayBuffer(file); + }); + + async function renderPages() { + for (let i = 1; i <= pdfDoc.numPages; i++) { + await renderPage(i); + } + } + + async function renderPage(num) { + const page = await pdfDoc.getPage(num); + const viewport = page.getViewport({ scale: scale }); + + // Create Wrapper + const wrapper = document.createElement('div'); + wrapper.className = 'page-wrapper'; + wrapper.style.width = `${viewport.width}px`; + wrapper.style.height = `${viewport.height}px`; + wrapper.dataset.pageIndex = num; + + // Create Canvas + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + // Render PDF to Canvas + const renderContext = { + canvasContext: context, + viewport: viewport + }; + + // Wait for render to finish + await page.render(renderContext).promise; + + // Create Text Layer + const textLayerDiv = document.createElement('div'); + textLayerDiv.className = 'textLayer'; + textLayerDiv.style.width = `${viewport.width}px`; + textLayerDiv.style.height = `${viewport.height}px`; + + try { + const textContent = await page.getTextContent(); + await pdfjsLib.renderTextLayer({ + textContentSource: textContent, + container: textLayerDiv, + viewport: viewport, + textDivs: [] + }).promise; + } catch (error) { + console.warn(`Text layer rendering failed for page ${num}:`, error); + } + + // Create Overlay for Drawing + const overlay = document.createElement('div'); + overlay.className = 'page-overlay'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + + // Interaction Logic + setupDrawing(overlay, num); + + wrapper.appendChild(canvas); + wrapper.appendChild(textLayerDiv); // Text Layer sits between Canvas and Overlay + wrapper.appendChild(overlay); + container.appendChild(wrapper); + } + + + function makeInteractive(box, overlay, pageNum) { + // Add Delete Button + const delBtn = document.createElement('div'); + delBtn.className = 'delete-btn'; + delBtn.innerText = '×'; + delBtn.title = 'Remove Redaction'; + delBtn.addEventListener('mousedown', (e) => { + e.stopPropagation(); // Stop drag start + box.remove(); + removeFromState(pageNum, box); + }); + box.appendChild(delBtn); + + // Add Resize Handles + const positions = ['nw', 'ne', 'sw', 'se']; + positions.forEach(pos => { + const handle = document.createElement('div'); + handle.className = `resize-handle handle-${pos}`; + handle.dataset.pos = pos; + box.appendChild(handle); + setupResize(handle, box); + }); + + // Dragging Logic + box.addEventListener('mousedown', (e) => { + if (e.target !== box) return; // Only drag if clicking body, not handles/buttons + e.preventDefault(); // Prevent text selection + + const startX = e.clientX; + const startY = e.clientY; + const startLeft = parseFloat(box.style.left); + const startTop = parseFloat(box.style.top); + const maxX = overlay.offsetWidth - box.offsetWidth; + const maxY = overlay.offsetHeight - box.offsetHeight; + + function onMouseMove(moveEvent) { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + + let newLeft = startLeft + dx; + let newTop = startTop + dy; + + // Constrain to overlay + newLeft = Math.max(0, Math.min(newLeft, maxX)); + newTop = Math.max(0, Math.min(newTop, maxY)); + + box.style.left = `${newLeft}px`; + box.style.top = `${newTop}px`; + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + } + + function setupResize(handle, box) { + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const pos = handle.dataset.pos; + const startX = e.clientX; + const startY = e.clientY; + const startWidth = parseFloat(box.style.width); + const startHeight = parseFloat(box.style.height); + const startLeft = parseFloat(box.style.left); + const startTop = parseFloat(box.style.top); + + function onMouseMove(moveEvent) { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + + if (pos.includes('e')) { + box.style.width = `${Math.max(10, startWidth + dx)}px`; + } + if (pos.includes('s')) { + box.style.height = `${Math.max(10, startHeight + dy)}px`; + } + if (pos.includes('w')) { + const newWidth = Math.max(10, startWidth - dx); + box.style.width = `${newWidth}px`; + // Adjust left only if width actually changed (not at min limit) + if (newWidth !== 10) { + box.style.left = `${startLeft + dx}px`; + } + } + if (pos.includes('n')) { + const newHeight = Math.max(10, startHeight - dy); + box.style.height = `${newHeight}px`; + if (newHeight !== 10) { + box.style.top = `${startTop + dy}px`; + } + } + } + + function onMouseUp() { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + } + + function removeFromState(pageNum, boxElement) { + const list = redactionsMap.get(pageNum); + if (list) { + const index = list.indexOf(boxElement); + if (index > -1) { + list.splice(index, 1); + } + } + } + + // --- Export Logic --- + + exportBtn.addEventListener('click', async () => { + if (!pdfDoc) return; + + exportBtn.disabled = true; + exportBtn.innerText = 'Processing...'; + loader.style.display = 'block'; + + // Wait a moment for UI to update + await new Promise(resolve => setTimeout(resolve, 100)); + + try { + const { jsPDF } = window.jspdf; + const doc = new jsPDF({ unit: 'pt', orientation: 'p' }); + doc.deletePage(1); + + for (let i = 1; i <= pdfDoc.numPages; i++) { + const page = await pdfDoc.getPage(i); + const exportScale = 2.0; + const viewport = page.getViewport({ scale: exportScale }); + + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext('2d'); + + await page.render({ canvasContext: ctx, viewport: viewport }).promise; + + // Apply Redactions + if (redactionsMap.has(i)) { + ctx.fillStyle = '#000000'; // Solid black + const scaleRatio = exportScale / scale; + + const boxes = redactionsMap.get(i); + boxes.forEach(box => { + const rect = { + x: parseFloat(box.style.left), + y: parseFloat(box.style.top), + w: parseFloat(box.style.width), + h: parseFloat(box.style.height) + }; + + ctx.fillRect( + rect.x * scaleRatio, + rect.y * scaleRatio, + rect.w * scaleRatio, + rect.h * scaleRatio + ); + }); + } + + const imgData = canvas.toDataURL('image/jpeg', 0.8); + const originalViewport = page.getViewport({ scale: 1.0 }); + + doc.addPage([originalViewport.width, originalViewport.height]); + doc.addImage(imgData, 'JPEG', 0, 0, originalViewport.width, originalViewport.height); + } + + doc.save('redacted-document.pdf'); + + } catch (err) { + console.error("Export failed:", err); + alert("Export failed. See console."); + } finally { + exportBtn.disabled = false; + exportBtn.innerText = '🔒 Export Redacted PDF'; + loader.style.display = 'none'; + } + }); + + </script> +</body> +</html> |
