From 19863cc7204c66ea32451db42ba76fd77c3d999d Mon Sep 17 00:00:00 2001 From: Theo <80580619+theodubus@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:16:45 +0100 Subject: [PATCH 01/50] feat(frontend): add cursor selection redaction rule --- frontend/src/App.tsx | 74 ++++++++-- frontend/src/api.ts | 11 +- frontend/src/components/PdfViewer.tsx | 136 +++++++++++++++--- .../src/components/Rules/EditRuleModal.tsx | 4 +- frontend/src/components/Rules/RulesList.tsx | 22 +-- .../src/components/Rules/RulesSection.tsx | 45 ++++-- frontend/src/locales/en.json | 18 +-- frontend/src/locales/fr.json | 18 +-- frontend/src/types/uiRules.ts | 14 ++ 9 files changed, 259 insertions(+), 83 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..becdf08 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,14 +10,20 @@ import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); + const [pendingSelection, setPendingSelection] = useState(null); const [presets, setPresets] = useState>({ email: false, phone: false, @@ -52,25 +58,34 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; @@ -81,6 +96,7 @@ export default function App() { const f = e.target.files?.[0] ?? null; if (!f) { setFile(null); + setPendingSelection(null); return; } @@ -92,6 +108,7 @@ export default function App() { } setFile(f); + setPendingSelection(null); }; const togglePreset = (key: PresetKey) => { @@ -99,6 +116,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +154,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -161,7 +197,12 @@ export default function App() {
{file ? ( - + ) : ( <>
{t("viewer.placeholder.title")}
@@ -178,6 +219,9 @@ export default function App() { rules={rules} setRules={setRules} onUserChange={clearNotices} + pendingSelectionText={pendingSelection?.text ?? ""} + canAddSelection={!!pendingSelection && pendingSelection.rects.length > 0} + onAddSelection={addPendingSelection} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..a596c7e 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -50,8 +50,9 @@ export function PdfViewer(props: { file: File; rules: UiRule[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +63,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +86,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +119,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +141,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,7 +185,7 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, pageNumber - 1, scale); } })(); @@ -191,11 +197,80 @@ export function PdfViewer(props: { useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, index, scale); } }, [rules]); + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); + if (error) { return
{error}
; } @@ -236,9 +311,10 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -260,27 +336,53 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } +} + +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); } function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..42653a8 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -36,10 +36,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index 4ad1cdc..cbb0725 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -4,7 +4,7 @@ import { truncate } from "../../utils/redactionUtils"; export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind) => string; + kindLabel: (k: RuleKind | "selection") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -60,15 +60,17 @@ export function RulesList(props: {
- + {r.kind !== "selection" ? ( + + ) : null}
- {/* petit espace avant l’input */}
- - {/* Input + Ajouter sur la même ligne */} + + + {pendingSelectionText ? ( +
+ {t("rules.selection.current")}: {pendingSelectionText} +
+ ) : ( +
+ {t("rules.selection.none")} +
+ )} + Date: Sat, 14 Feb 2026 15:40:56 +0100 Subject: [PATCH 02/50] fix(frontend): reset rules on document change and tighten rule item display --- frontend/src/App.tsx | 75 ++++++++-- frontend/src/api.ts | 11 +- frontend/src/components/PdfViewer.tsx | 136 +++++++++++++++--- .../src/components/Rules/EditRuleModal.tsx | 4 +- frontend/src/components/Rules/RulesList.tsx | 45 ++++-- .../src/components/Rules/RulesSection.tsx | 53 +++++-- frontend/src/locales/en.json | 18 +-- frontend/src/locales/fr.json | 18 +-- frontend/src/types/uiRules.ts | 14 ++ 9 files changed, 288 insertions(+), 86 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..4d0509e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,14 +10,20 @@ import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); + const [pendingSelection, setPendingSelection] = useState(null); const [presets, setPresets] = useState>({ email: false, phone: false, @@ -52,25 +58,34 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; @@ -81,6 +96,7 @@ export default function App() { const f = e.target.files?.[0] ?? null; if (!f) { setFile(null); + setPendingSelection(null); return; } @@ -92,6 +108,8 @@ export default function App() { } setFile(f); + setPendingSelection(null); + setRules([]); }; const togglePreset = (key: PresetKey) => { @@ -99,6 +117,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +155,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -161,7 +198,12 @@ export default function App() {
{file ? ( - + ) : ( <>
{t("viewer.placeholder.title")}
@@ -178,6 +220,9 @@ export default function App() { rules={rules} setRules={setRules} onUserChange={clearNotices} + pendingSelectionText={pendingSelection?.text ?? ""} + canAddSelection={!!pendingSelection && pendingSelection.rects.length > 0} + onAddSelection={addPendingSelection} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..a596c7e 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -50,8 +50,9 @@ export function PdfViewer(props: { file: File; rules: UiRule[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +63,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +86,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +119,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +141,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,7 +185,7 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, pageNumber - 1, scale); } })(); @@ -191,11 +197,80 @@ export function PdfViewer(props: { useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, index, scale); } }, [rules]); + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); + if (error) { return
{error}
; } @@ -236,9 +311,10 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -260,27 +336,53 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } +} + +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); } function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..42653a8 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -36,10 +36,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index 4ad1cdc..02f57d9 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -1,10 +1,26 @@ +import type { CSSProperties } from "react"; import type { RuleKind, UiRule } from "../../types/uiRules"; import { truncate } from "../../utils/redactionUtils"; +const actionButtonStyle: CSSProperties = { + border: "1px solid #ddd", + borderRadius: 10, + width: 34, + minWidth: 34, + height: 34, + padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + background: "#fff", + cursor: "pointer", + fontWeight: 700, +}; + export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind) => string; + kindLabel: (k: RuleKind | "selection") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -39,9 +55,10 @@ export function RulesList(props: { borderRadius: 10, padding: "10px 12px", gap: 12, + minHeight: 58, }} > -
+
- {truncate(r.value)} + {truncate(r.value, 34)}
@@ -60,18 +77,20 @@ export function RulesList(props: {
+ {r.kind !== "selection" ? ( + + ) : null} -
- {/* petit espace avant l’input */}
- - {/* Input + Ajouter sur la même ligne */} + + + {pendingSelectionText ? ( +
+ {t("rules.selection.current")}: {summarizeSelection(pendingSelectionText)} +
+ ) : ( +
+ {t("rules.selection.none")} +
+ )} + Date: Sat, 14 Feb 2026 15:51:00 +0100 Subject: [PATCH 03/50] feat(frontend): add preset preview highlights and reset presets on file change --- frontend/src/App.tsx | 81 ++++++-- frontend/src/api.ts | 11 +- frontend/src/components/PdfViewer.tsx | 177 +++++++++++++++--- .../src/components/Rules/EditRuleModal.tsx | 4 +- frontend/src/components/Rules/RulesList.tsx | 45 +++-- .../src/components/Rules/RulesSection.tsx | 53 ++++-- frontend/src/locales/en.json | 18 +- frontend/src/locales/fr.json | 18 +- frontend/src/types/uiRules.ts | 14 ++ 9 files changed, 331 insertions(+), 90 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..d7969d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,14 +10,20 @@ import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); + const [pendingSelection, setPendingSelection] = useState(null); const [presets, setPresets] = useState>({ email: false, phone: false, @@ -52,25 +58,34 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; @@ -81,6 +96,7 @@ export default function App() { const f = e.target.files?.[0] ?? null; if (!f) { setFile(null); + setPendingSelection(null); return; } @@ -92,6 +108,13 @@ export default function App() { } setFile(f); + setPendingSelection(null); + setRules([]); + setPresets({ + email: false, + phone: false, + credit_card: false, + }); }; const togglePreset = (key: PresetKey) => { @@ -99,6 +122,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +160,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -161,7 +203,13 @@ export default function App() {
{file ? ( - + ) : ( <>
{t("viewer.placeholder.title")}
@@ -178,6 +226,9 @@ export default function App() { rules={rules} setRules={setRules} onUserChange={clearNotices} + pendingSelectionText={pendingSelection?.text ?? ""} + canAddSelection={!!pendingSelection && pendingSelection.rects.length > 0} + onAddSelection={addPendingSelection} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..26898c0 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { PresetKey } from "../api"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -49,9 +50,11 @@ async function ensurePdfJsLoaded(): Promise { export function PdfViewer(props: { file: File; rules: UiRule[]; + presetKeys: PresetKey[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, presetKeys, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +65,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +88,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +121,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +143,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,22 +187,91 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, pageNumber - 1, scale); } })(); return () => { cancelled = true; }; - }, [pdfDoc, pageNumbers, containerWidth, rules]); + }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys]); useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, index, scale); } - }, [rules]); + }, [rules, presetKeys]); + + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); if (error) { return
{error}
; @@ -236,9 +313,11 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + presetKeys: PresetKey[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -251,7 +330,7 @@ function applyPreviewHighlights( const raw = textNode.textContent ?? ""; if (!raw) continue; - const ranges = collectMatches(raw, rules); + const ranges = collectMatches(raw, rules, presetKeys); if (ranges.length === 0) continue; for (const rangeDef of ranges) { @@ -260,27 +339,57 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } } -function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); +} + +function collectMatches( + text: string, + rules: UiRule[], + presetKeys: PresetKey[], +): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = @@ -289,9 +398,35 @@ function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; e : findRegexMatches(text, value, rule.caseSensitive, rule.ignoreAccents, rule.allowSubwords); matches.push(...ranges); } + matches.push(...findPresetMatches(text, presetKeys)); return mergeRanges(matches); } + +function findPresetMatches(text: string, presetKeys: PresetKey[]) { + const ranges: Array<{ start: number; end: number }> = []; + + for (const key of presetKeys) { + let regex: RegExp; + if (key === "email") { + regex = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi; + } else if (key === "phone") { + regex = /\b(?:\+|00)?\s*(?:\d[\s().-]?){6,20}\d\b/gi; + } else { + regex = /\b(?:\d[ -]*?){13,19}\b/gi; + } + + for (const match of text.matchAll(regex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + ranges.push({ start: match.index, end: match.index + value.length }); + } + } + + return ranges; +} + function findExactMatches( text: string, query: string, diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..42653a8 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -36,10 +36,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index 4ad1cdc..02f57d9 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -1,10 +1,26 @@ +import type { CSSProperties } from "react"; import type { RuleKind, UiRule } from "../../types/uiRules"; import { truncate } from "../../utils/redactionUtils"; +const actionButtonStyle: CSSProperties = { + border: "1px solid #ddd", + borderRadius: 10, + width: 34, + minWidth: 34, + height: 34, + padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + background: "#fff", + cursor: "pointer", + fontWeight: 700, +}; + export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind) => string; + kindLabel: (k: RuleKind | "selection") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -39,9 +55,10 @@ export function RulesList(props: { borderRadius: 10, padding: "10px 12px", gap: 12, + minHeight: 58, }} > -
+
- {truncate(r.value)} + {truncate(r.value, 34)}
@@ -60,18 +77,20 @@ export function RulesList(props: {
+ {r.kind !== "selection" ? ( + + ) : null} -
- {/* petit espace avant l’input */}
- - {/* Input + Ajouter sur la même ligne */} + + + {pendingSelectionText ? ( +
+ {t("rules.selection.current")}: {summarizeSelection(pendingSelectionText)} +
+ ) : ( +
+ {t("rules.selection.none")} +
+ )} + Date: Sat, 14 Feb 2026 16:00:26 +0100 Subject: [PATCH 04/50] fix(frontend): preview multiline phone presets across text nodes --- frontend/src/App.tsx | 81 ++++-- frontend/src/api.ts | 11 +- frontend/src/components/PdfViewer.tsx | 253 ++++++++++++++++-- .../src/components/Rules/EditRuleModal.tsx | 4 +- frontend/src/components/Rules/RulesList.tsx | 45 +++- .../src/components/Rules/RulesSection.tsx | 53 +++- frontend/src/locales/en.json | 18 +- frontend/src/locales/fr.json | 18 +- frontend/src/types/uiRules.ts | 14 + 9 files changed, 407 insertions(+), 90 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..d7969d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,14 +10,20 @@ import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); + const [pendingSelection, setPendingSelection] = useState(null); const [presets, setPresets] = useState>({ email: false, phone: false, @@ -52,25 +58,34 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; @@ -81,6 +96,7 @@ export default function App() { const f = e.target.files?.[0] ?? null; if (!f) { setFile(null); + setPendingSelection(null); return; } @@ -92,6 +108,13 @@ export default function App() { } setFile(f); + setPendingSelection(null); + setRules([]); + setPresets({ + email: false, + phone: false, + credit_card: false, + }); }; const togglePreset = (key: PresetKey) => { @@ -99,6 +122,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +160,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -161,7 +203,13 @@ export default function App() {
{file ? ( - + ) : ( <>
{t("viewer.placeholder.title")}
@@ -178,6 +226,9 @@ export default function App() { rules={rules} setRules={setRules} onUserChange={clearNotices} + pendingSelectionText={pendingSelection?.text ?? ""} + canAddSelection={!!pendingSelection && pendingSelection.rects.length > 0} + onAddSelection={addPendingSelection} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..e7272eb 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { PresetKey } from "../api"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -49,9 +50,11 @@ async function ensurePdfJsLoaded(): Promise { export function PdfViewer(props: { file: File; rules: UiRule[]; + presetKeys: PresetKey[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, presetKeys, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +65,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +88,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +121,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +143,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,22 +187,91 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, pageNumber - 1, scale); } })(); return () => { cancelled = true; }; - }, [pdfDoc, pageNumbers, containerWidth, rules]); + }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys]); useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, index, scale); } - }, [rules]); + }, [rules, presetKeys]); + + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); if (error) { return
{error}
; @@ -236,9 +313,11 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + presetKeys: PresetKey[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -251,7 +330,7 @@ function applyPreviewHighlights( const raw = textNode.textContent ?? ""; if (!raw) continue; - const ranges = collectMatches(raw, rules); + const ranges = collectMatches(raw, rules, presetKeys); if (ranges.length === 0) continue; for (const rangeDef of ranges) { @@ -260,27 +339,61 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + if (presetKeys.includes("phone")) { + highlightPhonePresetMatches(textLayer, previewLayer, layerBounds); + } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } } -function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); +} + +function collectMatches( + text: string, + rules: UiRule[], + presetKeys: PresetKey[], +): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = @@ -289,9 +402,107 @@ function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; e : findRegexMatches(text, value, rule.caseSensitive, rule.ignoreAccents, rule.allowSubwords); matches.push(...ranges); } + matches.push(...findPresetMatches(text, presetKeys)); return mergeRanges(matches); } + +const PHONE_PRESET_REGEX = /\b(?:\+|00)?\s*(?:\d[\s().-]?){6,20}\d\b/gi; + +function findPresetMatches(text: string, presetKeys: PresetKey[]) { + const ranges: Array<{ start: number; end: number }> = []; + + for (const key of presetKeys) { + if (key === "phone") continue; + + const regex = + key === "email" + ? /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi + : /\b(?:\d[ -]*?){13,19}\b/gi; + + for (const match of text.matchAll(regex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + ranges.push({ start: match.index, end: match.index + value.length }); + } + } + + return ranges; +} + +type LayerTextNode = { + node: Text; + start: number; + end: number; +}; + +function highlightPhonePresetMatches( + textLayer: HTMLDivElement, + previewLayer: HTMLDivElement, + layerBounds: DOMRect, +) { + const { fullText, nodes } = collectLayerTextNodes(textLayer); + if (!fullText || nodes.length === 0) return; + + const phoneRegex = new RegExp(PHONE_PRESET_REGEX.source, PHONE_PRESET_REGEX.flags); + for (const match of fullText.matchAll(phoneRegex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + + const matchStart = match.index; + const matchEnd = match.index + value.length; + + for (const item of nodes) { + const localStart = Math.max(matchStart, item.start) - item.start; + const localEnd = Math.min(matchEnd, item.end) - item.start; + if (localEnd <= localStart) continue; + + const range = document.createRange(); + range.setStart(item.node, localStart); + range.setEnd(item.node, localEnd); + + for (const rect of range.getClientRects()) { + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); + } + range.detach(); + } + } +} + +function collectLayerTextNodes(textLayer: HTMLDivElement) { + const nodes: LayerTextNode[] = []; + let fullText = ""; + let cursor = 0; + + const spans = textLayer.querySelectorAll("span"); + for (const span of spans) { + const textNode = span.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const value = textNode.textContent ?? ""; + if (!value) continue; + + const start = cursor; + const end = start + value.length; + nodes.push({ node: textNode as Text, start, end }); + + fullText += value; + cursor = end; + + fullText += "\n"; + cursor += 1; + } + + return { fullText, nodes }; +} + function findExactMatches( text: string, query: string, diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..42653a8 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -36,10 +36,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index 4ad1cdc..02f57d9 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -1,10 +1,26 @@ +import type { CSSProperties } from "react"; import type { RuleKind, UiRule } from "../../types/uiRules"; import { truncate } from "../../utils/redactionUtils"; +const actionButtonStyle: CSSProperties = { + border: "1px solid #ddd", + borderRadius: 10, + width: 34, + minWidth: 34, + height: 34, + padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + background: "#fff", + cursor: "pointer", + fontWeight: 700, +}; + export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind) => string; + kindLabel: (k: RuleKind | "selection") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -39,9 +55,10 @@ export function RulesList(props: { borderRadius: 10, padding: "10px 12px", gap: 12, + minHeight: 58, }} > -
+
- {truncate(r.value)} + {truncate(r.value, 34)}
@@ -60,18 +77,20 @@ export function RulesList(props: {
+ {r.kind !== "selection" ? ( + + ) : null} -
- {/* petit espace avant l’input */}
- - {/* Input + Ajouter sur la même ligne */} + + + {pendingSelectionText ? ( +
+ {t("rules.selection.current")}: {summarizeSelection(pendingSelectionText)} +
+ ) : ( +
+ {t("rules.selection.none")} +
+ )} + Date: Sat, 14 Feb 2026 16:10:50 +0100 Subject: [PATCH 05/50] fix(frontend): reduce phone preset preview false positives --- frontend/src/App.tsx | 81 +++++- frontend/src/api.ts | 11 +- frontend/src/components/PdfViewer.tsx | 270 ++++++++++++++++-- .../src/components/Rules/EditRuleModal.tsx | 4 +- frontend/src/components/Rules/RulesList.tsx | 45 ++- .../src/components/Rules/RulesSection.tsx | 53 +++- frontend/src/locales/en.json | 18 +- frontend/src/locales/fr.json | 18 +- frontend/src/types/uiRules.ts | 14 + 9 files changed, 424 insertions(+), 90 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..d7969d1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,14 +10,20 @@ import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); + const [pendingSelection, setPendingSelection] = useState(null); const [presets, setPresets] = useState>({ email: false, phone: false, @@ -52,25 +58,34 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; @@ -81,6 +96,7 @@ export default function App() { const f = e.target.files?.[0] ?? null; if (!f) { setFile(null); + setPendingSelection(null); return; } @@ -92,6 +108,13 @@ export default function App() { } setFile(f); + setPendingSelection(null); + setRules([]); + setPresets({ + email: false, + phone: false, + credit_card: false, + }); }; const togglePreset = (key: PresetKey) => { @@ -99,6 +122,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +160,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -161,7 +203,13 @@ export default function App() {
{file ? ( - + ) : ( <>
{t("viewer.placeholder.title")}
@@ -178,6 +226,9 @@ export default function App() { rules={rules} setRules={setRules} onUserChange={clearNotices} + pendingSelectionText={pendingSelection?.text ?? ""} + canAddSelection={!!pendingSelection && pendingSelection.rects.length > 0} + onAddSelection={addPendingSelection} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..f4d1a87 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { PresetKey } from "../api"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -49,9 +50,11 @@ async function ensurePdfJsLoaded(): Promise { export function PdfViewer(props: { file: File; rules: UiRule[]; + presetKeys: PresetKey[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, presetKeys, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +65,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +88,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +121,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +143,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,22 +187,91 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, pageNumber - 1, scale); } })(); return () => { cancelled = true; }; - }, [pdfDoc, pageNumbers, containerWidth, rules]); + }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys]); useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, index, scale); } - }, [rules]); + }, [rules, presetKeys]); + + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); if (error) { return
{error}
; @@ -236,9 +313,11 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + presetKeys: PresetKey[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -251,7 +330,7 @@ function applyPreviewHighlights( const raw = textNode.textContent ?? ""; if (!raw) continue; - const ranges = collectMatches(raw, rules); + const ranges = collectMatches(raw, rules, presetKeys); if (ranges.length === 0) continue; for (const rangeDef of ranges) { @@ -260,27 +339,61 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + if (presetKeys.includes("phone")) { + highlightPhonePresetMatches(textLayer, previewLayer, layerBounds); + } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } +} + +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); } -function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { +function collectMatches( + text: string, + rules: UiRule[], + presetKeys: PresetKey[], +): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = @@ -289,9 +402,124 @@ function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; e : findRegexMatches(text, value, rule.caseSensitive, rule.ignoreAccents, rule.allowSubwords); matches.push(...ranges); } + matches.push(...findPresetMatches(text, presetKeys)); return mergeRanges(matches); } + +const PHONE_PRESET_REGEX = /\b(?:\+|00)?\s*(?:\d[\s().-]?){6,20}\d\b/gi; + +function findPresetMatches(text: string, presetKeys: PresetKey[]) { + const ranges: Array<{ start: number; end: number }> = []; + + for (const key of presetKeys) { + if (key === "phone") continue; + + const regex = + key === "email" + ? /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi + : /\b(?:\d[ -]*?){13,19}\b/gi; + + for (const match of text.matchAll(regex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + ranges.push({ start: match.index, end: match.index + value.length }); + } + } + + return ranges; +} + +function isLikelyPhonePresetMatch(rawMatch: string) { + const value = rawMatch.trim(); + if (!value) return false; + if (/[A-Za-z/]/.test(value)) return false; + + const hasIntlPrefix = /^\s*(?:\+|00)/.test(value); + const digits = value.replace(/\D+/g, ""); + const minDigits = hasIntlPrefix ? 8 : 10; + if (digits.length < minDigits || digits.length > 15) return false; + + if (!hasIntlPrefix && !digits.startsWith("0")) return false; + if (!/[+\s().-]/.test(value)) return false; + + return true; +} + +type LayerTextNode = { + node: Text; + start: number; + end: number; +}; + +function highlightPhonePresetMatches( + textLayer: HTMLDivElement, + previewLayer: HTMLDivElement, + layerBounds: DOMRect, +) { + const { fullText, nodes } = collectLayerTextNodes(textLayer); + if (!fullText || nodes.length === 0) return; + + const phoneRegex = new RegExp(PHONE_PRESET_REGEX.source, PHONE_PRESET_REGEX.flags); + for (const match of fullText.matchAll(phoneRegex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + if (!isLikelyPhonePresetMatch(value)) continue; + + const matchStart = match.index; + const matchEnd = match.index + value.length; + + for (const item of nodes) { + const localStart = Math.max(matchStart, item.start) - item.start; + const localEnd = Math.min(matchEnd, item.end) - item.start; + if (localEnd <= localStart) continue; + + const range = document.createRange(); + range.setStart(item.node, localStart); + range.setEnd(item.node, localEnd); + + for (const rect of range.getClientRects()) { + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); + } + range.detach(); + } + } +} + +function collectLayerTextNodes(textLayer: HTMLDivElement) { + const nodes: LayerTextNode[] = []; + let fullText = ""; + let cursor = 0; + + const spans = textLayer.querySelectorAll("span"); + for (const span of spans) { + const textNode = span.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const value = textNode.textContent ?? ""; + if (!value) continue; + + const start = cursor; + const end = start + value.length; + nodes.push({ node: textNode as Text, start, end }); + + fullText += value; + cursor = end; + + fullText += "\n"; + cursor += 1; + } + + return { fullText, nodes }; +} + function findExactMatches( text: string, query: string, diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..42653a8 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -36,10 +36,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index 4ad1cdc..02f57d9 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -1,10 +1,26 @@ +import type { CSSProperties } from "react"; import type { RuleKind, UiRule } from "../../types/uiRules"; import { truncate } from "../../utils/redactionUtils"; +const actionButtonStyle: CSSProperties = { + border: "1px solid #ddd", + borderRadius: 10, + width: 34, + minWidth: 34, + height: 34, + padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + background: "#fff", + cursor: "pointer", + fontWeight: 700, +}; + export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind) => string; + kindLabel: (k: RuleKind | "selection") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -39,9 +55,10 @@ export function RulesList(props: { borderRadius: 10, padding: "10px 12px", gap: 12, + minHeight: 58, }} > -
+
- {truncate(r.value)} + {truncate(r.value, 34)}
@@ -60,18 +77,20 @@ export function RulesList(props: {
+ {r.kind !== "selection" ? ( + + ) : null} -
- {/* petit espace avant l’input */}
- - {/* Input + Ajouter sur la même ligne */} + + + {pendingSelectionText ? ( +
+ {t("rules.selection.current")}: {summarizeSelection(pendingSelectionText)} +
+ ) : ( +
+ {t("rules.selection.none")} +
+ )} + Date: Sat, 14 Feb 2026 21:26:02 +0100 Subject: [PATCH 06/50] feat(frontend): reshape layout with top band and inline viewer/tools --- frontend/src/App.tsx | 218 +++++++++----- frontend/src/api.ts | 11 +- frontend/src/components/HeaderBar.tsx | 16 +- frontend/src/components/PdfViewer.tsx | 270 ++++++++++++++++-- .../src/components/Rules/EditRuleModal.tsx | 4 +- frontend/src/components/Rules/RulesList.tsx | 45 ++- .../src/components/Rules/RulesSection.tsx | 53 +++- frontend/src/locales/en.json | 21 +- frontend/src/locales/fr.json | 21 +- frontend/src/styles.css | 152 ++++------ frontend/src/types/uiRules.ts | 14 + 11 files changed, 585 insertions(+), 240 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..ad2d94a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,36 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { useI18n } from "./i18n"; import { redactApply } from "./api"; import type { PresetKey, RuleInput } from "./api"; import { HeaderBar } from "./components/HeaderBar"; -import { FilePickerSection } from "./components/FilePickerSection"; import { RulesSection } from "./components/Rules/RulesSection"; import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; + +const EMPTY_PRESETS: Record = { + email: false, + phone: false, + credit_card: false, +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); - const [presets, setPresets] = useState>({ - email: false, - phone: false, - credit_card: false, - }); + const [pendingSelection, setPendingSelection] = useState(null); + const [presets, setPresets] = useState>(EMPTY_PRESETS); + const [isDragOver, setIsDragOver] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -42,6 +50,8 @@ export default function App() { rawMessage?: string; } | null>(null); + const fileInputRef = useRef(null); + const clearNotices = () => { setSuccessInfo(null); setErrorInfo(null); @@ -52,46 +62,64 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; - const onPickFile: React.ChangeEventHandler = (e) => { + const loadPdfFile = (pickedFile: File | null) => { clearNotices(); - const f = e.target.files?.[0] ?? null; - if (!f) { + if (!pickedFile) { setFile(null); + setPendingSelection(null); return; } - const isPdf = f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"); + const isPdf = + pickedFile.type === "application/pdf" || pickedFile.name.toLowerCase().endsWith(".pdf"); if (!isPdf) { setFile(null); setErrorInfo({ rawMessage: t("form.file.invalidType") }); return; } - setFile(f); + setFile(pickedFile); + setPendingSelection(null); + setRules([]); + setPresets(EMPTY_PRESETS); + }; + + const onPickFile: React.ChangeEventHandler = (e) => { + loadPdfFile(e.target.files?.[0] ?? null); + e.currentTarget.value = ""; }; const togglePreset = (key: PresetKey) => { @@ -99,6 +127,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +165,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -152,54 +199,83 @@ export default function App() { }; return ( -
- - -
-
- - -
- {file ? ( - - ) : ( - <> -
{t("viewer.placeholder.title")}
-

{t("viewer.placeholder.body")}

- - )} -
-
- -
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/HeaderBar.tsx b/frontend/src/components/HeaderBar.tsx index c361707..6948345 100644 --- a/frontend/src/components/HeaderBar.tsx +++ b/frontend/src/components/HeaderBar.tsx @@ -2,14 +2,20 @@ export function HeaderBar(props: { lang: "fr" | "en"; setLang: (l: "fr" | "en") => void; t: (k: string) => string; + fileName?: string; + onChangeFile?: () => void; }) { - const { lang, setLang, t } = props; + const { lang, setLang, t, fileName, onChangeFile } = props; return ( -
-
-
{t("app.title")}
-
{t("app.subtitle")}
+
+
+ {onChangeFile ? ( + + ) : null} + {fileName ?
{fileName}
: null}
diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..f4d1a87 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { PresetKey } from "../api"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -49,9 +50,11 @@ async function ensurePdfJsLoaded(): Promise { export function PdfViewer(props: { file: File; rules: UiRule[]; + presetKeys: PresetKey[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, presetKeys, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +65,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +88,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +121,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +143,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,22 +187,91 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, pageNumber - 1, scale); } })(); return () => { cancelled = true; }; - }, [pdfDoc, pageNumbers, containerWidth, rules]); + }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys]); useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, index, scale); } - }, [rules]); + }, [rules, presetKeys]); + + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); if (error) { return
{error}
; @@ -236,9 +313,11 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + presetKeys: PresetKey[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -251,7 +330,7 @@ function applyPreviewHighlights( const raw = textNode.textContent ?? ""; if (!raw) continue; - const ranges = collectMatches(raw, rules); + const ranges = collectMatches(raw, rules, presetKeys); if (ranges.length === 0) continue; for (const rangeDef of ranges) { @@ -260,27 +339,61 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + if (presetKeys.includes("phone")) { + highlightPhonePresetMatches(textLayer, previewLayer, layerBounds); + } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } +} + +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); } -function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { +function collectMatches( + text: string, + rules: UiRule[], + presetKeys: PresetKey[], +): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = @@ -289,9 +402,124 @@ function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; e : findRegexMatches(text, value, rule.caseSensitive, rule.ignoreAccents, rule.allowSubwords); matches.push(...ranges); } + matches.push(...findPresetMatches(text, presetKeys)); return mergeRanges(matches); } + +const PHONE_PRESET_REGEX = /\b(?:\+|00)?\s*(?:\d[\s().-]?){6,20}\d\b/gi; + +function findPresetMatches(text: string, presetKeys: PresetKey[]) { + const ranges: Array<{ start: number; end: number }> = []; + + for (const key of presetKeys) { + if (key === "phone") continue; + + const regex = + key === "email" + ? /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi + : /\b(?:\d[ -]*?){13,19}\b/gi; + + for (const match of text.matchAll(regex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + ranges.push({ start: match.index, end: match.index + value.length }); + } + } + + return ranges; +} + +function isLikelyPhonePresetMatch(rawMatch: string) { + const value = rawMatch.trim(); + if (!value) return false; + if (/[A-Za-z/]/.test(value)) return false; + + const hasIntlPrefix = /^\s*(?:\+|00)/.test(value); + const digits = value.replace(/\D+/g, ""); + const minDigits = hasIntlPrefix ? 8 : 10; + if (digits.length < minDigits || digits.length > 15) return false; + + if (!hasIntlPrefix && !digits.startsWith("0")) return false; + if (!/[+\s().-]/.test(value)) return false; + + return true; +} + +type LayerTextNode = { + node: Text; + start: number; + end: number; +}; + +function highlightPhonePresetMatches( + textLayer: HTMLDivElement, + previewLayer: HTMLDivElement, + layerBounds: DOMRect, +) { + const { fullText, nodes } = collectLayerTextNodes(textLayer); + if (!fullText || nodes.length === 0) return; + + const phoneRegex = new RegExp(PHONE_PRESET_REGEX.source, PHONE_PRESET_REGEX.flags); + for (const match of fullText.matchAll(phoneRegex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + if (!isLikelyPhonePresetMatch(value)) continue; + + const matchStart = match.index; + const matchEnd = match.index + value.length; + + for (const item of nodes) { + const localStart = Math.max(matchStart, item.start) - item.start; + const localEnd = Math.min(matchEnd, item.end) - item.start; + if (localEnd <= localStart) continue; + + const range = document.createRange(); + range.setStart(item.node, localStart); + range.setEnd(item.node, localEnd); + + for (const rect of range.getClientRects()) { + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); + } + range.detach(); + } + } +} + +function collectLayerTextNodes(textLayer: HTMLDivElement) { + const nodes: LayerTextNode[] = []; + let fullText = ""; + let cursor = 0; + + const spans = textLayer.querySelectorAll("span"); + for (const span of spans) { + const textNode = span.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const value = textNode.textContent ?? ""; + if (!value) continue; + + const start = cursor; + const end = start + value.length; + nodes.push({ node: textNode as Text, start, end }); + + fullText += value; + cursor = end; + + fullText += "\n"; + cursor += 1; + } + + return { fullText, nodes }; +} + function findExactMatches( text: string, query: string, diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..42653a8 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -36,10 +36,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index 4ad1cdc..02f57d9 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -1,10 +1,26 @@ +import type { CSSProperties } from "react"; import type { RuleKind, UiRule } from "../../types/uiRules"; import { truncate } from "../../utils/redactionUtils"; +const actionButtonStyle: CSSProperties = { + border: "1px solid #ddd", + borderRadius: 10, + width: 34, + minWidth: 34, + height: 34, + padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + background: "#fff", + cursor: "pointer", + fontWeight: 700, +}; + export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind) => string; + kindLabel: (k: RuleKind | "selection") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -39,9 +55,10 @@ export function RulesList(props: { borderRadius: 10, padding: "10px 12px", gap: 12, + minHeight: 58, }} > -
+
- {truncate(r.value)} + {truncate(r.value, 34)}
@@ -60,18 +77,20 @@ export function RulesList(props: {
+ {r.kind !== "selection" ? ( + + ) : null} -
- {/* petit espace avant l’input */}
- - {/* Input + Ajouter sur la même ligne */} + + + {pendingSelectionText ? ( +
+ {t("rules.selection.current")}: {summarizeSelection(pendingSelectionText)} +
+ ) : ( +
+ {t("rules.selection.none")} +
+ )} + Date: Sat, 14 Feb 2026 21:40:12 +0100 Subject: [PATCH 07/50] fix(frontend): make workspace full-width with fixed top band --- frontend/src/App.tsx | 218 +++++++++----- frontend/src/api.ts | 11 +- frontend/src/components/HeaderBar.tsx | 16 +- frontend/src/components/PdfViewer.tsx | 270 ++++++++++++++++-- .../src/components/Rules/EditRuleModal.tsx | 4 +- frontend/src/components/Rules/RulesList.tsx | 45 ++- .../src/components/Rules/RulesSection.tsx | 53 +++- frontend/src/locales/en.json | 21 +- frontend/src/locales/fr.json | 21 +- frontend/src/styles.css | 166 +++++------ frontend/src/types/uiRules.ts | 14 + 11 files changed, 597 insertions(+), 242 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..ad2d94a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,36 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { useI18n } from "./i18n"; import { redactApply } from "./api"; import type { PresetKey, RuleInput } from "./api"; import { HeaderBar } from "./components/HeaderBar"; -import { FilePickerSection } from "./components/FilePickerSection"; import { RulesSection } from "./components/Rules/RulesSection"; import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; + +const EMPTY_PRESETS: Record = { + email: false, + phone: false, + credit_card: false, +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); - const [presets, setPresets] = useState>({ - email: false, - phone: false, - credit_card: false, - }); + const [pendingSelection, setPendingSelection] = useState(null); + const [presets, setPresets] = useState>(EMPTY_PRESETS); + const [isDragOver, setIsDragOver] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -42,6 +50,8 @@ export default function App() { rawMessage?: string; } | null>(null); + const fileInputRef = useRef(null); + const clearNotices = () => { setSuccessInfo(null); setErrorInfo(null); @@ -52,46 +62,64 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; - const onPickFile: React.ChangeEventHandler = (e) => { + const loadPdfFile = (pickedFile: File | null) => { clearNotices(); - const f = e.target.files?.[0] ?? null; - if (!f) { + if (!pickedFile) { setFile(null); + setPendingSelection(null); return; } - const isPdf = f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"); + const isPdf = + pickedFile.type === "application/pdf" || pickedFile.name.toLowerCase().endsWith(".pdf"); if (!isPdf) { setFile(null); setErrorInfo({ rawMessage: t("form.file.invalidType") }); return; } - setFile(f); + setFile(pickedFile); + setPendingSelection(null); + setRules([]); + setPresets(EMPTY_PRESETS); + }; + + const onPickFile: React.ChangeEventHandler = (e) => { + loadPdfFile(e.target.files?.[0] ?? null); + e.currentTarget.value = ""; }; const togglePreset = (key: PresetKey) => { @@ -99,6 +127,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +165,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -152,54 +199,83 @@ export default function App() { }; return ( -
- - -
-
- - -
- {file ? ( - - ) : ( - <> -
{t("viewer.placeholder.title")}
-

{t("viewer.placeholder.body")}

- - )} -
-
- -
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/HeaderBar.tsx b/frontend/src/components/HeaderBar.tsx index c361707..6948345 100644 --- a/frontend/src/components/HeaderBar.tsx +++ b/frontend/src/components/HeaderBar.tsx @@ -2,14 +2,20 @@ export function HeaderBar(props: { lang: "fr" | "en"; setLang: (l: "fr" | "en") => void; t: (k: string) => string; + fileName?: string; + onChangeFile?: () => void; }) { - const { lang, setLang, t } = props; + const { lang, setLang, t, fileName, onChangeFile } = props; return ( -
-
-
{t("app.title")}
-
{t("app.subtitle")}
+
+
+ {onChangeFile ? ( + + ) : null} + {fileName ?
{fileName}
: null}
diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..f4d1a87 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { PresetKey } from "../api"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -49,9 +50,11 @@ async function ensurePdfJsLoaded(): Promise { export function PdfViewer(props: { file: File; rules: UiRule[]; + presetKeys: PresetKey[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, presetKeys, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +65,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +88,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +121,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +143,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,22 +187,91 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, pageNumber - 1, scale); } })(); return () => { cancelled = true; }; - }, [pdfDoc, pageNumbers, containerWidth, rules]); + }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys]); useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, index, scale); } - }, [rules]); + }, [rules, presetKeys]); + + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); if (error) { return
{error}
; @@ -236,9 +313,11 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + presetKeys: PresetKey[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -251,7 +330,7 @@ function applyPreviewHighlights( const raw = textNode.textContent ?? ""; if (!raw) continue; - const ranges = collectMatches(raw, rules); + const ranges = collectMatches(raw, rules, presetKeys); if (ranges.length === 0) continue; for (const rangeDef of ranges) { @@ -260,27 +339,61 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + if (presetKeys.includes("phone")) { + highlightPhonePresetMatches(textLayer, previewLayer, layerBounds); + } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } +} + +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); } -function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { +function collectMatches( + text: string, + rules: UiRule[], + presetKeys: PresetKey[], +): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = @@ -289,9 +402,124 @@ function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; e : findRegexMatches(text, value, rule.caseSensitive, rule.ignoreAccents, rule.allowSubwords); matches.push(...ranges); } + matches.push(...findPresetMatches(text, presetKeys)); return mergeRanges(matches); } + +const PHONE_PRESET_REGEX = /\b(?:\+|00)?\s*(?:\d[\s().-]?){6,20}\d\b/gi; + +function findPresetMatches(text: string, presetKeys: PresetKey[]) { + const ranges: Array<{ start: number; end: number }> = []; + + for (const key of presetKeys) { + if (key === "phone") continue; + + const regex = + key === "email" + ? /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi + : /\b(?:\d[ -]*?){13,19}\b/gi; + + for (const match of text.matchAll(regex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + ranges.push({ start: match.index, end: match.index + value.length }); + } + } + + return ranges; +} + +function isLikelyPhonePresetMatch(rawMatch: string) { + const value = rawMatch.trim(); + if (!value) return false; + if (/[A-Za-z/]/.test(value)) return false; + + const hasIntlPrefix = /^\s*(?:\+|00)/.test(value); + const digits = value.replace(/\D+/g, ""); + const minDigits = hasIntlPrefix ? 8 : 10; + if (digits.length < minDigits || digits.length > 15) return false; + + if (!hasIntlPrefix && !digits.startsWith("0")) return false; + if (!/[+\s().-]/.test(value)) return false; + + return true; +} + +type LayerTextNode = { + node: Text; + start: number; + end: number; +}; + +function highlightPhonePresetMatches( + textLayer: HTMLDivElement, + previewLayer: HTMLDivElement, + layerBounds: DOMRect, +) { + const { fullText, nodes } = collectLayerTextNodes(textLayer); + if (!fullText || nodes.length === 0) return; + + const phoneRegex = new RegExp(PHONE_PRESET_REGEX.source, PHONE_PRESET_REGEX.flags); + for (const match of fullText.matchAll(phoneRegex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + if (!isLikelyPhonePresetMatch(value)) continue; + + const matchStart = match.index; + const matchEnd = match.index + value.length; + + for (const item of nodes) { + const localStart = Math.max(matchStart, item.start) - item.start; + const localEnd = Math.min(matchEnd, item.end) - item.start; + if (localEnd <= localStart) continue; + + const range = document.createRange(); + range.setStart(item.node, localStart); + range.setEnd(item.node, localEnd); + + for (const rect of range.getClientRects()) { + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); + } + range.detach(); + } + } +} + +function collectLayerTextNodes(textLayer: HTMLDivElement) { + const nodes: LayerTextNode[] = []; + let fullText = ""; + let cursor = 0; + + const spans = textLayer.querySelectorAll("span"); + for (const span of spans) { + const textNode = span.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const value = textNode.textContent ?? ""; + if (!value) continue; + + const start = cursor; + const end = start + value.length; + nodes.push({ node: textNode as Text, start, end }); + + fullText += value; + cursor = end; + + fullText += "\n"; + cursor += 1; + } + + return { fullText, nodes }; +} + function findExactMatches( text: string, query: string, diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..42653a8 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -36,10 +36,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); diff --git a/frontend/src/components/Rules/RulesList.tsx b/frontend/src/components/Rules/RulesList.tsx index 4ad1cdc..02f57d9 100644 --- a/frontend/src/components/Rules/RulesList.tsx +++ b/frontend/src/components/Rules/RulesList.tsx @@ -1,10 +1,26 @@ +import type { CSSProperties } from "react"; import type { RuleKind, UiRule } from "../../types/uiRules"; import { truncate } from "../../utils/redactionUtils"; +const actionButtonStyle: CSSProperties = { + border: "1px solid #ddd", + borderRadius: 10, + width: 34, + minWidth: 34, + height: 34, + padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + background: "#fff", + cursor: "pointer", + fontWeight: 700, +}; + export function RulesList(props: { t: (k: string) => string; rules: UiRule[]; - kindLabel: (k: RuleKind) => string; + kindLabel: (k: RuleKind | "selection") => string; onEdit: (r: UiRule) => void; onDelete: (id: string) => void; }) { @@ -39,9 +55,10 @@ export function RulesList(props: { borderRadius: 10, padding: "10px 12px", gap: 12, + minHeight: 58, }} > -
+
- {truncate(r.value)} + {truncate(r.value, 34)}
@@ -60,18 +77,20 @@ export function RulesList(props: {
+ {r.kind !== "selection" ? ( + + ) : null} -
- {/* petit espace avant l’input */}
- - {/* Input + Ajouter sur la même ligne */} + + + {pendingSelectionText ? ( +
+ {t("rules.selection.current")}: {summarizeSelection(pendingSelectionText)} +
+ ) : ( +
+ {t("rules.selection.none")} +
+ )} + Date: Sat, 14 Feb 2026 21:57:26 +0100 Subject: [PATCH 08/50] feat(frontend): simplify search controls and restore edit modal styling --- frontend/src/App.tsx | 218 +++++++++----- frontend/src/api.ts | 11 +- frontend/src/components/HeaderBar.tsx | 16 +- frontend/src/components/PdfViewer.tsx | 270 ++++++++++++++++-- .../src/components/Rules/EditRuleModal.tsx | 59 ++-- frontend/src/components/Rules/RuleAddBar.tsx | 3 - .../src/components/Rules/RuleKindToggle.tsx | 24 +- .../src/components/Rules/RuleOptionsRow.tsx | 32 ++- frontend/src/components/Rules/RulesList.tsx | 45 ++- .../src/components/Rules/RulesSection.tsx | 143 +++++----- frontend/src/locales/en.json | 21 +- frontend/src/locales/fr.json | 21 +- frontend/src/styles.css | 166 +++++------ frontend/src/types/uiRules.ts | 14 + 14 files changed, 675 insertions(+), 368 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9828124..ad2d94a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,36 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import { useI18n } from "./i18n"; import { redactApply } from "./api"; import type { PresetKey, RuleInput } from "./api"; import { HeaderBar } from "./components/HeaderBar"; -import { FilePickerSection } from "./components/FilePickerSection"; import { RulesSection } from "./components/Rules/RulesSection"; import { PresetsSection } from "./components/PresetsSection"; import { ResultPanel } from "./components/ResultPanel"; import { PdfViewer } from "./components/PdfViewer"; -import type { UiRule } from "./types/uiRules"; -import { downloadBlob } from "./utils/redactionUtils"; +import type { UiRect, UiRule } from "./types/uiRules"; +import { downloadBlob, newId } from "./utils/redactionUtils"; + +type PendingSelection = { + text: string; + rects: UiRect[]; +}; + +const EMPTY_PRESETS: Record = { + email: false, + phone: false, + credit_card: false, +}; export default function App() { const { lang, setLang, t } = useI18n(); const [file, setFile] = useState(null); const [rules, setRules] = useState([]); - const [presets, setPresets] = useState>({ - email: false, - phone: false, - credit_card: false, - }); + const [pendingSelection, setPendingSelection] = useState(null); + const [presets, setPresets] = useState>(EMPTY_PRESETS); + const [isDragOver, setIsDragOver] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -42,6 +50,8 @@ export default function App() { rawMessage?: string; } | null>(null); + const fileInputRef = useRef(null); + const clearNotices = () => { setSuccessInfo(null); setErrorInfo(null); @@ -52,46 +62,64 @@ export default function App() { }, [presets]); const rulesForApi: RuleInput[] = useMemo(() => { - return rules.map((r) => { + const mapped: RuleInput[] = []; + for (const r of rules) { if (r.kind === "exact") { - return { + mapped.push({ kind: "exact", query: r.value, caseSensitive: r.caseSensitive, allowSubwords: r.allowSubwords, ignoreAccents: r.ignoreAccents, - }; + }); + continue; } - return { - kind: "regex", - pattern: r.value, - caseSensitive: r.caseSensitive, - multiline: r.multiline, - allowSubwords: r.allowSubwords, - ignoreAccents: r.ignoreAccents, - }; - }); + if (r.kind === "regex") { + mapped.push({ + kind: "regex", + pattern: r.value, + caseSensitive: r.caseSensitive, + multiline: r.multiline, + allowSubwords: r.allowSubwords, + ignoreAccents: r.ignoreAccents, + }); + } + } + return mapped; + }, [rules]); + + const rectsForApi = useMemo(() => { + return rules.flatMap((r) => (r.kind === "selection" ? r.rects : [])); }, [rules]); const hasAnythingToDo = rules.length > 0 || selectedPresets.length > 0; - const onPickFile: React.ChangeEventHandler = (e) => { + const loadPdfFile = (pickedFile: File | null) => { clearNotices(); - const f = e.target.files?.[0] ?? null; - if (!f) { + if (!pickedFile) { setFile(null); + setPendingSelection(null); return; } - const isPdf = f.type === "application/pdf" || f.name.toLowerCase().endsWith(".pdf"); + const isPdf = + pickedFile.type === "application/pdf" || pickedFile.name.toLowerCase().endsWith(".pdf"); if (!isPdf) { setFile(null); setErrorInfo({ rawMessage: t("form.file.invalidType") }); return; } - setFile(f); + setFile(pickedFile); + setPendingSelection(null); + setRules([]); + setPresets(EMPTY_PRESETS); + }; + + const onPickFile: React.ChangeEventHandler = (e) => { + loadPdfFile(e.target.files?.[0] ?? null); + e.currentTarget.value = ""; }; const togglePreset = (key: PresetKey) => { @@ -99,6 +127,24 @@ export default function App() { setPresets((p) => ({ ...p, [key]: !p[key] })); }; + const addPendingSelection = () => { + if (!pendingSelection) return; + clearNotices(); + + const trimmed = pendingSelection.text.trim(); + if (!trimmed || pendingSelection.rects.length === 0) return; + + const newRule: UiRule = { + id: newId(), + kind: "selection", + value: trimmed, + rects: pendingSelection.rects, + }; + + setRules((prev) => [...prev, newRule]); + setPendingSelection(null); + }; + const handleSubmit: React.FormEventHandler = async (e) => { e.preventDefault(); setSuccessInfo(null); @@ -119,6 +165,7 @@ export default function App() { try { const r = await redactApply({ file, + rects: rectsForApi, rules: rulesForApi, presets: selectedPresets, }); @@ -152,54 +199,83 @@ export default function App() { }; return ( -
- - -
-
- - -
- {file ? ( - - ) : ( - <> -
{t("viewer.placeholder.title")}
-

{t("viewer.placeholder.body")}

- - )} -
-
- -
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f6e42f6..360248a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,14 @@ export type AuditReport = unknown; export type PresetKey = "email" | "phone" | "credit_card"; +export type RectInput = { + page: number; + x0: number; + y0: number; + x1: number; + y1: number; +}; + export type RuleInput = | { kind: "exact"; @@ -59,6 +67,7 @@ function wrapWholeWordRegex(pattern: string): string { export async function redactApply(params: { file: File; + rects: RectInput[]; rules: RuleInput[]; presets: PresetKey[]; }): Promise { @@ -98,7 +107,7 @@ export async function redactApply(params: { const hasPresets = params.presets.length > 0; const payload = { - rects: [], + rects: params.rects, searches, regexes, presets: hasPresets ? { presets: params.presets, scope: { pages: null as null } } : null, diff --git a/frontend/src/components/HeaderBar.tsx b/frontend/src/components/HeaderBar.tsx index c361707..6948345 100644 --- a/frontend/src/components/HeaderBar.tsx +++ b/frontend/src/components/HeaderBar.tsx @@ -2,14 +2,20 @@ export function HeaderBar(props: { lang: "fr" | "en"; setLang: (l: "fr" | "en") => void; t: (k: string) => string; + fileName?: string; + onChangeFile?: () => void; }) { - const { lang, setLang, t } = props; + const { lang, setLang, t, fileName, onChangeFile } = props; return ( -
-
-
{t("app.title")}
-
{t("app.subtitle")}
+
+
+ {onChangeFile ? ( + + ) : null} + {fileName ?
{fileName}
: null}
diff --git a/frontend/src/components/PdfViewer.tsx b/frontend/src/components/PdfViewer.tsx index a6f4f16..f4d1a87 100644 --- a/frontend/src/components/PdfViewer.tsx +++ b/frontend/src/components/PdfViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import type { UiRule } from "../types/uiRules"; +import type { PresetKey } from "../api"; +import type { UiRect, UiRule } from "../types/uiRules"; type PdfViewport = { width: number; @@ -49,9 +50,11 @@ async function ensurePdfJsLoaded(): Promise { export function PdfViewer(props: { file: File; rules: UiRule[]; + presetKeys: PresetKey[]; t: (k: string) => string; + onSelectionChange: (selection: { text: string; rects: UiRect[] } | null) => void; }) { - const { file, rules, t } = props; + const { file, rules, presetKeys, t, onSelectionChange } = props; const [pdfDoc, setPdfDoc] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -62,6 +65,7 @@ export function PdfViewer(props: { const canvasRefs = useRef>([]); const textLayerRefs = useRef>([]); const previewLayerRefs = useRef>([]); + const pageScalesRef = useRef>([]); const pdfjsRef = useRef(null); useEffect(() => { @@ -84,6 +88,8 @@ export function PdfViewer(props: { let active = true; let loadedDoc: PdfDocumentProxy | null = null; + onSelectionChange(null); + pageScalesRef.current = []; setPdfDoc(null); setError(null); setIsLoading(true); @@ -115,7 +121,7 @@ export function PdfViewer(props: { active = false; if (loadedDoc) loadedDoc.destroy(); }; - }, [file, t]); + }, [file, onSelectionChange, t]); const pageNumbers = useMemo(() => { if (!pdfDoc) return []; @@ -137,6 +143,8 @@ export function PdfViewer(props: { const scale = containerWidth / baseViewport.width; const viewport = page.getViewport({ scale }); + pageScalesRef.current[pageNumber - 1] = scale; + const canvas = canvasRefs.current[pageNumber - 1]; const textLayer = textLayerRefs.current[pageNumber - 1]; const previewLayer = previewLayerRefs.current[pageNumber - 1]; @@ -179,22 +187,91 @@ export function PdfViewer(props: { viewport, }); await textLayerTask.render(); - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, pageNumber - 1, scale); } })(); return () => { cancelled = true; }; - }, [pdfDoc, pageNumbers, containerWidth, rules]); + }, [pdfDoc, pageNumbers, containerWidth, rules, presetKeys]); useEffect(() => { for (const [index, textLayer] of textLayerRefs.current.entries()) { const previewLayer = previewLayerRefs.current[index]; + const scale = pageScalesRef.current[index] ?? 1; if (!textLayer || !previewLayer) continue; - applyPreviewHighlights(textLayer, previewLayer, rules); + applyPreviewHighlights(textLayer, previewLayer, rules, presetKeys, index, scale); } - }, [rules]); + }, [rules, presetKeys]); + + useEffect(() => { + const computeSelection = () => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + onSelectionChange(null); + return; + } + + const selectedText = selection.toString().trim(); + if (!selectedText) { + onSelectionChange(null); + return; + } + + const range = selection.getRangeAt(0); + const anchorNode = range.commonAncestorContainer; + if (!anchorNode) { + onSelectionChange(null); + return; + } + + const pageIndex = textLayerRefs.current.findIndex((layer) => layer?.contains(anchorNode) ?? false); + if (pageIndex < 0) { + onSelectionChange(null); + return; + } + + const textLayer = textLayerRefs.current[pageIndex]; + const scale = pageScalesRef.current[pageIndex] ?? 1; + if (!textLayer || scale <= 0) { + onSelectionChange(null); + return; + } + + const layerBounds = textLayer.getBoundingClientRect(); + const rects: UiRect[] = []; + + for (const rect of range.getClientRects()) { + const localLeft = rect.left - layerBounds.left; + const localRight = rect.right - layerBounds.left; + const localTop = rect.top - layerBounds.top; + const localBottom = rect.bottom - layerBounds.top; + + if (localRight <= localLeft || localBottom <= localTop) continue; + + rects.push({ + page: pageIndex, + x0: localLeft / scale, + y0: localTop / scale, + x1: localRight / scale, + y1: localBottom / scale, + }); + } + + if (rects.length === 0) { + onSelectionChange(null); + return; + } + + onSelectionChange({ text: selectedText, rects }); + }; + + document.addEventListener("selectionchange", computeSelection); + return () => { + document.removeEventListener("selectionchange", computeSelection); + }; + }, [onSelectionChange]); if (error) { return
{error}
; @@ -236,9 +313,11 @@ function applyPreviewHighlights( textLayer: HTMLDivElement, previewLayer: HTMLDivElement, rules: UiRule[], + presetKeys: PresetKey[], + pageIndex: number, + scale: number, ) { previewLayer.replaceChildren(); - if (!rules.length) return; const layerBounds = textLayer.getBoundingClientRect(); if (!layerBounds.width || !layerBounds.height) return; @@ -251,7 +330,7 @@ function applyPreviewHighlights( const raw = textNode.textContent ?? ""; if (!raw) continue; - const ranges = collectMatches(raw, rules); + const ranges = collectMatches(raw, rules, presetKeys); if (ranges.length === 0) continue; for (const rangeDef of ranges) { @@ -260,27 +339,61 @@ function applyPreviewHighlights( range.setEnd(textNode, rangeDef.end); for (const rect of range.getClientRects()) { - const width = rect.width; - const height = rect.height; - if (!width || !height) continue; - - const highlight = document.createElement("div"); - highlight.className = "redactionPreviewRect"; - highlight.style.left = `${rect.left - layerBounds.left}px`; - highlight.style.top = `${rect.top - layerBounds.top}px`; - highlight.style.width = `${width}px`; - highlight.style.height = `${height}px`; - previewLayer.appendChild(highlight); + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); } range.detach(); } } + + if (presetKeys.includes("phone")) { + highlightPhonePresetMatches(textLayer, previewLayer, layerBounds); + } + + const selectionRules = rules.filter((r): r is Extract => r.kind === "selection"); + for (const selectionRule of selectionRules) { + for (const rect of selectionRule.rects) { + if (rect.page !== pageIndex) continue; + drawPreviewRect(previewLayer, { + left: rect.x0 * scale, + top: rect.y0 * scale, + width: (rect.x1 - rect.x0) * scale, + height: (rect.y1 - rect.y0) * scale, + }); + } + } +} + +function drawPreviewRect( + previewLayer: HTMLDivElement, + rect: { left: number; top: number; width: number; height: number }, +) { + const width = rect.width; + const height = rect.height; + if (!width || !height) return; + + const highlight = document.createElement("div"); + highlight.className = "redactionPreviewRect"; + highlight.style.left = `${rect.left}px`; + highlight.style.top = `${rect.top}px`; + highlight.style.width = `${width}px`; + highlight.style.height = `${height}px`; + previewLayer.appendChild(highlight); } -function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; end: number }> { +function collectMatches( + text: string, + rules: UiRule[], + presetKeys: PresetKey[], +): Array<{ start: number; end: number }> { const matches: Array<{ start: number; end: number }> = []; for (const rule of rules) { + if (rule.kind === "selection") continue; const value = rule.value.trim(); if (!value) continue; const ranges = @@ -289,9 +402,124 @@ function collectMatches(text: string, rules: UiRule[]): Array<{ start: number; e : findRegexMatches(text, value, rule.caseSensitive, rule.ignoreAccents, rule.allowSubwords); matches.push(...ranges); } + matches.push(...findPresetMatches(text, presetKeys)); return mergeRanges(matches); } + +const PHONE_PRESET_REGEX = /\b(?:\+|00)?\s*(?:\d[\s().-]?){6,20}\d\b/gi; + +function findPresetMatches(text: string, presetKeys: PresetKey[]) { + const ranges: Array<{ start: number; end: number }> = []; + + for (const key of presetKeys) { + if (key === "phone") continue; + + const regex = + key === "email" + ? /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi + : /\b(?:\d[ -]*?){13,19}\b/gi; + + for (const match of text.matchAll(regex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + ranges.push({ start: match.index, end: match.index + value.length }); + } + } + + return ranges; +} + +function isLikelyPhonePresetMatch(rawMatch: string) { + const value = rawMatch.trim(); + if (!value) return false; + if (/[A-Za-z/]/.test(value)) return false; + + const hasIntlPrefix = /^\s*(?:\+|00)/.test(value); + const digits = value.replace(/\D+/g, ""); + const minDigits = hasIntlPrefix ? 8 : 10; + if (digits.length < minDigits || digits.length > 15) return false; + + if (!hasIntlPrefix && !digits.startsWith("0")) return false; + if (!/[+\s().-]/.test(value)) return false; + + return true; +} + +type LayerTextNode = { + node: Text; + start: number; + end: number; +}; + +function highlightPhonePresetMatches( + textLayer: HTMLDivElement, + previewLayer: HTMLDivElement, + layerBounds: DOMRect, +) { + const { fullText, nodes } = collectLayerTextNodes(textLayer); + if (!fullText || nodes.length === 0) return; + + const phoneRegex = new RegExp(PHONE_PRESET_REGEX.source, PHONE_PRESET_REGEX.flags); + for (const match of fullText.matchAll(phoneRegex)) { + if (typeof match.index !== "number") continue; + const value = match[0] ?? ""; + if (!value) continue; + if (!isLikelyPhonePresetMatch(value)) continue; + + const matchStart = match.index; + const matchEnd = match.index + value.length; + + for (const item of nodes) { + const localStart = Math.max(matchStart, item.start) - item.start; + const localEnd = Math.min(matchEnd, item.end) - item.start; + if (localEnd <= localStart) continue; + + const range = document.createRange(); + range.setStart(item.node, localStart); + range.setEnd(item.node, localEnd); + + for (const rect of range.getClientRects()) { + drawPreviewRect(previewLayer, { + left: rect.left - layerBounds.left, + top: rect.top - layerBounds.top, + width: rect.width, + height: rect.height, + }); + } + range.detach(); + } + } +} + +function collectLayerTextNodes(textLayer: HTMLDivElement) { + const nodes: LayerTextNode[] = []; + let fullText = ""; + let cursor = 0; + + const spans = textLayer.querySelectorAll("span"); + for (const span of spans) { + const textNode = span.firstChild; + if (!textNode || textNode.nodeType !== Node.TEXT_NODE) continue; + + const value = textNode.textContent ?? ""; + if (!value) continue; + + const start = cursor; + const end = start + value.length; + nodes.push({ node: textNode as Text, start, end }); + + fullText += value; + cursor = end; + + fullText += "\n"; + cursor += 1; + } + + return { fullText, nodes }; +} + function findExactMatches( text: string, query: string, diff --git a/frontend/src/components/Rules/EditRuleModal.tsx b/frontend/src/components/Rules/EditRuleModal.tsx index f6a4d6b..2279850 100644 --- a/frontend/src/components/Rules/EditRuleModal.tsx +++ b/frontend/src/components/Rules/EditRuleModal.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState } from "react"; import type { RuleKind, UiRule } from "../../types/uiRules"; -import { RuleKindToggle } from "./RuleKindToggle"; import { RuleOptionsRow } from "./RuleOptionsRow"; type UiRuleNoId = @@ -36,10 +35,10 @@ export function EditRuleModal(props: { const [editAllowSubwords, setEditAllowSubwords] = useState(false); const [editIgnoreAccents, setEditIgnoreAccents] = useState(false); - const isOpen = !!rule; + const isOpen = !!rule && rule.kind !== "selection"; useEffect(() => { - if (!rule) return; + if (!rule || rule.kind === "selection") return; setEditKind(rule.kind); setEditValue(rule.value); @@ -96,19 +95,22 @@ export function EditRuleModal(props: { alignItems: "center", justifyContent: "center", padding: 16, - zIndex: 50, + zIndex: 200, }} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }} >
e.stopPropagation()} > @@ -117,17 +119,6 @@ export function EditRuleModal(props: {
-
- {t("modal.edit.kind")} -
- -
- -
-
- {t("modal.edit.value")} -
- {editKind === "regex" ? (