diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58b8e78..b653fde 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,7 @@ export default function App() { const [pendingSelection, setPendingSelection] = useState(null); const [currentPage, setCurrentPage] = useState(null); const [pageSizes, setPageSizes] = useState>({}); + const [isDrawingRect, setIsDrawingRect] = useState(false); const [presets, setPresets] = useState>(EMPTY_PRESETS); const [isDragOver, setIsDragOver] = useState(false); @@ -44,6 +45,7 @@ export default function App() { } | null>(null); const fileInputRef = useRef(null); + const nextRectangleNumberRef = useRef(1); const clearNotices = () => { setErrorInfo(null); @@ -81,7 +83,9 @@ export default function App() { }, [rules]); const rectsForApi = useMemo(() => { - return rules.flatMap((r) => (r.kind === "selection" ? r.rects : r.kind === "page" ? [r.rect] : [])); + return rules.flatMap((r) => + r.kind === "selection" ? r.rects : r.kind === "page" || r.kind === "rectangle" ? [r.rect] : [] + ); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; @@ -104,6 +108,8 @@ export default function App() { setPendingSelection(null); setCurrentPage(null); setPageSizes({}); + setIsDrawingRect(false); + nextRectangleNumberRef.current = 1; return; } @@ -113,6 +119,8 @@ export default function App() { setFile(null); setCurrentPage(null); setPageSizes({}); + setIsDrawingRect(false); + nextRectangleNumberRef.current = 1; setErrorInfo({ rawMessage: t("form.file.invalidType") }); return; } @@ -121,6 +129,8 @@ export default function App() { setPendingSelection(null); setCurrentPage(1); setPageSizes({}); + setIsDrawingRect(false); + nextRectangleNumberRef.current = 1; setRules([]); setPresets(EMPTY_PRESETS); }; @@ -183,6 +193,24 @@ export default function App() { setRules((prev) => [...prev, newRule]); }; + + const addDrawnRectangleRule = (params: { pageNumber: number; rect: UiRect }) => { + clearNotices(); + const rectangleNumber = nextRectangleNumberRef.current; + nextRectangleNumberRef.current += 1; + + const newRule: UiRule = { + id: newId(), + kind: "rectangle", + value: `${t("rules.rectangle.title")} ${rectangleNumber} (${t("rules.page.title")} ${params.pageNumber})`, + rectangleNumber, + pageNumber: params.pageNumber, + rect: params.rect, + }; + + setRules((prev) => [...prev, newRule]); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setErrorInfo(null); @@ -244,6 +272,8 @@ export default function App() { onSelectionChange={setPendingSelection} onCurrentPageChange={setCurrentPage} onPageSizeChange={handlePageSizeChange} + isDrawingRect={isDrawingRect} + onAddDrawnRect={addDrawnRectangleRule} /> ) : (
0} onAddSelection={addPendingSelection} + isDrawingRect={isDrawingRect} + onToggleDrawSelection={() => setIsDrawingRect((prev) => !prev)} canCensorPage={!!currentPage && !!pageSizes[currentPage]} onCensorPage={addCurrentPageRule} /> diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index e59caec..4389c8a 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; import type { PresetKey } from "../api"; import type { UiRect, UiRule } from "../types/uiRules"; @@ -28,6 +29,15 @@ type PdfDocumentProxy = { destroy: () => void; }; + +type DrawDraft = { + pageIndex: number; + startX: number; + startY: number; + currentX: number; + currentY: number; +}; + type PdfJsLib = { GlobalWorkerOptions: { workerSrc: string }; getDocument: (params: { data: Uint8Array }) => { promise: Promise }; @@ -55,8 +65,10 @@ export function PdfViewer(props: { onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; onCurrentPageChange: (pageNumber: number | null) => void; onPageSizeChange: (pageNumber: number, size: { width: number; height: number }) => void; + isDrawingRect: boolean; + onAddDrawnRect: (params: { pageNumber: number; rect: UiRect }) => void; }) { - const { file, rules, presetKeys, t, onSelectionChange, onCurrentPageChange, onPageSizeChange } = props; + const { file, rules, presetKeys, t, onSelectionChange, onCurrentPageChange, onPageSizeChange, isDrawingRect, onAddDrawnRect } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -70,6 +82,7 @@ export function PdfViewer(props: { const pageRefs = useRef>([]); const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); + const [drawDraft, setDrawDraft] = useState(null); useEffect(() => { const el = containerRef.current; @@ -97,6 +110,7 @@ export function PdfViewer(props: { setPdfDoc(null); setError(null); setIsLoading(true); + setDrawDraft(null); (async () => { try { @@ -244,6 +258,11 @@ export function PdfViewer(props: { useEffect(() => { const computeSelection = () => { + if (isDrawingRect) { + onSelectionChange(null); + return; + } + const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { onSelectionChange(null); @@ -309,7 +328,77 @@ export function PdfViewer(props: { return () => { document.removeEventListener("selectionchange", computeSelection); }; - }, [onSelectionChange, onCurrentPageChange]); + }, [onSelectionChange, onCurrentPageChange, isDrawingRect]); + + + const clampPoint = (value: number, max: number) => Math.max(0, Math.min(value, max)); + + const beginDraw = (pageIndex: number, event: ReactPointerEvent) => { + if (!isDrawingRect) return; + + const layer = event.currentTarget; + const bounds = layer.getBoundingClientRect(); + const x = clampPoint(event.clientX - bounds.left, bounds.width); + const y = clampPoint(event.clientY - bounds.top, bounds.height); + + layer.setPointerCapture(event.pointerId); + setDrawDraft({ pageIndex, startX: x, startY: y, currentX: x, currentY: y }); + event.preventDefault(); + }; + + const moveDraw = (pageIndex: number, event: ReactPointerEvent) => { + if (!isDrawingRect || !drawDraft || drawDraft.pageIndex !== pageIndex) return; + + const layer = event.currentTarget; + const bounds = layer.getBoundingClientRect(); + const x = clampPoint(event.clientX - bounds.left, bounds.width); + const y = clampPoint(event.clientY - bounds.top, bounds.height); + + setDrawDraft((prev) => (prev && prev.pageIndex === pageIndex ? { ...prev, currentX: x, currentY: y } : prev)); + event.preventDefault(); + }; + + const endDraw = (pageIndex: number, event: ReactPointerEvent) => { + if (!isDrawingRect || !drawDraft || drawDraft.pageIndex !== pageIndex) return; + + const layer = event.currentTarget; + if (layer.hasPointerCapture(event.pointerId)) { + layer.releasePointerCapture(event.pointerId); + } + + const bounds = layer.getBoundingClientRect(); + const x = clampPoint(event.clientX - bounds.left, bounds.width); + const y = clampPoint(event.clientY - bounds.top, bounds.height); + + const left = Math.min(drawDraft.startX, x); + const right = Math.max(drawDraft.startX, x); + const top = Math.min(drawDraft.startY, y); + const bottom = Math.max(drawDraft.startY, y); + + const minSize = 4; + if (right - left >= minSize && bottom - top >= minSize) { + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (scale > 0) { + onAddDrawnRect({ + pageNumber: pageIndex + 1, + rect: { + page: pageIndex, + x0: left / scale, + y0: top / scale, + x1: right / scale, + y1: bottom / scale, + }, + }); + } + } + + setDrawDraft(null); + event.preventDefault(); + }; + + const cancelDraw = () => { + if (drawDraft) setDrawDraft(null); + }; if (error) { return
{error}
; @@ -342,7 +431,26 @@ export function PdfViewer(props: { }} />
beginDraw(pageNumber - 1, event)} + onPointerMove={(event) => moveDraw(pageNumber - 1, event)} + onPointerUp={(event) => endDraw(pageNumber - 1, event)} + onPointerCancel={cancelDraw} + > + {drawDraft && drawDraft.pageIndex === pageNumber - 1 ? ( +
+ ) : null} +
+
{ textLayerRefs.current[pageNumber - 1] = el; }} @@ -411,6 +519,18 @@ function applyPreviewHighlights( }); } + const rectangleRules = rules.filter((r): r is Extract => r.kind === "rectangle"); + for (const rectangleRule of rectangleRules) { + if (rectangleRule.pageNumber !== pageIndex + 1) continue; + const rect = rectangleRule.rect; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }, String(rectangleRule.rectangleNumber)); + } + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); for (const selectionRule of selectionRules) { for (const rect of selectionRule.rects) { @@ -428,6 +548,7 @@ function applyPreviewHighlights( function drawPreviewRect( previewLayer: HTMLDivElement, rect: { left: number; top: number; width: number; height: number }, + label?: string, ) { const width = rect.width; const height = rect.height; @@ -439,6 +560,13 @@ function drawPreviewRect( highlight.style.top = `${rect.top}px`; highlight.style.width = `${width}px`; highlight.style.height = `${height}px`; + if (label) { + const badge = document.createElement("div"); + badge.className = "redactionPreviewRectLabel"; + badge.textContent = label; + highlight.appendChild(badge); + } + previewLayer.appendChild(highlight); } @@ -449,7 +577,7 @@ function collectMatches( ): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { - if (rule.kind === "selection" || rule.kind === "page") continue; + if (rule.kind === "selection" || rule.kind === "page" || rule.kind === "rectangle") continue; const value = rule.value.trim(); if (!value) continue; const ranges = diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index be4d394..6e28fb7 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -78,7 +78,7 @@ function RuleOptionPills(props: { export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind | "selection" | "page") => string; + kindLabel: (k: RuleKind | "selection" | "page" | "rectangle") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -138,11 +138,11 @@ export function RulesList(props: { {kindLabel(r.kind)}
- {r.kind !== "selection" && r.kind !== "page" ? : null} + {r.kind !== "selection" && r.kind !== "page" && r.kind !== "rectangle" ? : null}
- {r.kind !== "selection" && r.kind !== "page" ? ( + {r.kind !== "selection" && r.kind !== "page" && r.kind !== "rectangle" ? (