summaryrefslogtreecommitdiff
path: root/redact.html
diff options
context:
space:
mode:
authorTadas Vilkeliskis <vilkeliskis.t@gmail.com>2026-01-22 09:28:00 -0500
committerTadas Vilkeliskis <vilkeliskis.t@gmail.com>2026-01-22 09:28:00 -0500
commit2561e1c19d31b9565c9ab31f21bb48a53a2708b8 (patch)
treefbce970a2f38cf9fc951e28a7e839ede8254cdf3 /redact.html
add initial redactor implementation
Diffstat (limited to 'redact.html')
-rw-r--r--redact.html714
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>