interface interactive
This commit is contained in:
@@ -17,7 +17,7 @@ export const AnonymizationInterface = ({
|
||||
|
||||
const anonymizedTypes = new Set<string>();
|
||||
|
||||
if (outputText.includes("<PERSON>")) {
|
||||
if (outputText.includes("<PERSONNE>")) {
|
||||
anonymizedTypes.add("Prénoms");
|
||||
anonymizedTypes.add("Noms de famille");
|
||||
anonymizedTypes.add("Noms complets");
|
||||
|
||||
@@ -1,211 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { patterns } from "@/app/utils/highlightEntities";
|
||||
import {
|
||||
PresidioAnalyzerResult,
|
||||
EntityMapping,
|
||||
} from "@/app/config/entityLabels";
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
// L'API retourne des objets avec snake_case
|
||||
interface PresidioAnalyzerResult {
|
||||
entity_type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// La réponse de l'API utilise camelCase pour les clés principales
|
||||
// Interface pour la réponse de l'API process-document
|
||||
interface ProcessDocumentResponse {
|
||||
text?: string; // Texte original en cas de fallback
|
||||
text?: string;
|
||||
anonymizedText?: string;
|
||||
analyzerResults?: PresidioAnalyzerResult[];
|
||||
replacementValues?: Record<string, string>; // Nouvelle propriété
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Props du hook
|
||||
interface AnonymizationLogicProps {
|
||||
sourceText: string;
|
||||
fileContent: string;
|
||||
uploadedFile: File | null;
|
||||
setOutputText: (text: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setEntityMappings: (mappings: EntityMapping[]) => void;
|
||||
}
|
||||
|
||||
// NOUVEAU: Définir les types pour le paramètre de anonymizeData
|
||||
interface AnonymizeDataParams {
|
||||
file?: File | null;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la logique d'anonymisation.
|
||||
* Gère l'appel API et la création du tableau de mapping de manière simple et directe.
|
||||
*/
|
||||
export const useAnonymization = ({
|
||||
sourceText,
|
||||
fileContent,
|
||||
uploadedFile,
|
||||
setOutputText,
|
||||
setError,
|
||||
setEntityMappings,
|
||||
}: AnonymizationLogicProps) => {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const anonymizeData = async () => {
|
||||
const textToProcess = sourceText || fileContent || "";
|
||||
|
||||
if (!textToProcess.trim()) {
|
||||
setError(
|
||||
"Veuillez saisir du texte à anonymiser ou télécharger un fichier"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const anonymizeData = async ({ file, text }: AnonymizeDataParams) => {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setOutputText("");
|
||||
setEntityMappings([]);
|
||||
setOutputText("");
|
||||
|
||||
try {
|
||||
// ÉTAPE 1: Construire le FormData ici pour garantir le bon format
|
||||
const formData = new FormData();
|
||||
if (uploadedFile) {
|
||||
formData.append("file", uploadedFile);
|
||||
if (file) {
|
||||
formData.append("file", file);
|
||||
} else if (text) {
|
||||
// Si c'est du texte, on le transforme en Blob pour l'envoyer comme un fichier
|
||||
const textBlob = new Blob([text], { type: "text/plain" });
|
||||
formData.append("file", textBlob, "input.txt");
|
||||
} else {
|
||||
const textBlob = new Blob([textToProcess], { type: "text/plain" });
|
||||
const textFile = new File([textBlob], "input.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
formData.append("file", textFile);
|
||||
throw new Error("Aucune donnée à anonymiser (ni fichier, ni texte).");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/process-document", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
body: formData, // Le Content-Type sera automatiquement défini par le navigateur
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Erreur HTTP: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) errorMessage = errorData.error;
|
||||
} catch {
|
||||
/* Ignore */
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data: ProcessDocumentResponse = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(
|
||||
data.error || "Erreur lors de la communication avec l'API."
|
||||
);
|
||||
}
|
||||
|
||||
// Utiliser camelCase pour les propriétés de la réponse principale
|
||||
if (data.anonymizedText && data.analyzerResults) {
|
||||
setOutputText(data.anonymizedText);
|
||||
const originalText = data.text || "";
|
||||
const presidioResults = data.analyzerResults || [];
|
||||
const replacementValues = data.replacementValues || {}; // Récupérer les valeurs de remplacement
|
||||
|
||||
const entityTypeMap = new Map<string, string>();
|
||||
patterns.forEach((p) => {
|
||||
const match = p.regex.toString().match(/<([A-Z_]+)>/);
|
||||
if (match && match[1]) {
|
||||
entityTypeMap.set(match[1], p.label);
|
||||
}
|
||||
// 🔍 AJOUT DES CONSOLE.LOG POUR DÉBOGUER
|
||||
console.log("📊 Données reçues de Presidio:", {
|
||||
originalTextLength: originalText.length,
|
||||
presidioResultsCount: presidioResults.length,
|
||||
presidioResults: presidioResults,
|
||||
replacementValues: replacementValues,
|
||||
replacementValuesKeys: Object.keys(replacementValues),
|
||||
replacementValuesEntries: Object.entries(replacementValues)
|
||||
});
|
||||
|
||||
// ÉTAPE 2 : Passer le texte ORIGINAL à l'état de sortie.
|
||||
setOutputText(originalText);
|
||||
|
||||
// ÉTAPE 3 : Créer le tableau de mapping avec la nouvelle structure
|
||||
const sortedResults = [...presidioResults].sort(
|
||||
(a, b) => a.start - b.start
|
||||
);
|
||||
const mappings: EntityMapping[] = [];
|
||||
|
||||
// Dans la fonction anonymizeData, section création des mappings :
|
||||
for (const result of sortedResults) {
|
||||
const { entity_type, start, end } = result;
|
||||
const detectedText = originalText.substring(start, end);
|
||||
|
||||
// 🔍 CONSOLE.LOG POUR CHAQUE ENTITÉ
|
||||
console.log(`🔍 Entité détectée:`, {
|
||||
entity_type,
|
||||
detectedText,
|
||||
replacementFromMap: replacementValues[detectedText],
|
||||
fallback: `[${entity_type}]`
|
||||
});
|
||||
|
||||
// 1. Compter les occurrences de chaque tag d'entité dans le texte anonymisé
|
||||
const tagCounts = new Map<string, number>();
|
||||
data.analyzerResults.forEach((result) => {
|
||||
const tag = `<${result.entity_type}>`;
|
||||
if (!tagCounts.has(result.entity_type)) {
|
||||
const count = (
|
||||
data.anonymizedText?.match(new RegExp(tag, "g")) || []
|
||||
).length;
|
||||
tagCounts.set(result.entity_type, count);
|
||||
}
|
||||
mappings.push({
|
||||
entity_type: entity_type,
|
||||
start: start,
|
||||
end: end,
|
||||
text: detectedText,
|
||||
replacementValue: replacementValues[detectedText] || `[${entity_type}]`,
|
||||
displayName: replacementValues[detectedText] || `[${entity_type}]`, // Ajouter cette ligne
|
||||
customColor: undefined,
|
||||
});
|
||||
|
||||
// 2. Créer un mapping basé sur l'ordre d'apparition dans le texte anonymisé
|
||||
const uniqueMappings: EntityMapping[] = [];
|
||||
|
||||
// Vérifier que les données nécessaires sont disponibles
|
||||
if (!data.analyzerResults || !data.anonymizedText) {
|
||||
setEntityMappings([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const entityCounters = new Map<string, number>();
|
||||
|
||||
// Parcourir le texte anonymisé pour trouver les tags dans l'ordre
|
||||
const anonymizedText = data.anonymizedText;
|
||||
const allMatches: Array<{
|
||||
match: RegExpMatchArray;
|
||||
entityType: string;
|
||||
position: number;
|
||||
}> = [];
|
||||
|
||||
// Trouver tous les tags dans le texte anonymisé
|
||||
patterns.forEach(pattern => {
|
||||
const entityTypeKey = pattern.regex.toString().match(/<([A-Z_]+)>/)?.[1];
|
||||
if (entityTypeKey) {
|
||||
const regex = new RegExp(pattern.regex.source, 'g');
|
||||
let match;
|
||||
while ((match = regex.exec(anonymizedText)) !== null) {
|
||||
allMatches.push({
|
||||
match,
|
||||
entityType: entityTypeKey,
|
||||
position: match.index
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trier par position dans le texte anonymisé
|
||||
allMatches.sort((a, b) => a.position - b.position);
|
||||
|
||||
// Créer les mappings dans l'ordre d'apparition
|
||||
const seen = new Set<string>();
|
||||
allMatches.forEach(({ entityType }) => {
|
||||
const frenchLabel = entityTypeMap.get(entityType) || entityType;
|
||||
const currentCount = (entityCounters.get(entityType) || 0) + 1;
|
||||
entityCounters.set(entityType, currentCount);
|
||||
|
||||
// Trouver l'entité correspondante dans les résultats d'analyse
|
||||
const correspondingResult = data.analyzerResults
|
||||
?.filter(result => result.entity_type === entityType)
|
||||
.find(result => {
|
||||
const originalValue = textToProcess.substring(result.start, result.end);
|
||||
const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`;
|
||||
return !seen.has(uniqueKey);
|
||||
});
|
||||
|
||||
if (correspondingResult) {
|
||||
const originalValue = textToProcess.substring(
|
||||
correspondingResult.start,
|
||||
correspondingResult.end
|
||||
);
|
||||
const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`;
|
||||
|
||||
if (!seen.has(uniqueKey)) {
|
||||
uniqueMappings.push({
|
||||
entityType: frenchLabel,
|
||||
originalValue: originalValue,
|
||||
anonymizedValue: `${frenchLabel} [${currentCount}]`,
|
||||
startIndex: correspondingResult.start,
|
||||
endIndex: correspondingResult.end,
|
||||
});
|
||||
seen.add(uniqueKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setEntityMappings(uniqueMappings);
|
||||
} else if (data.text) {
|
||||
setOutputText(data.text);
|
||||
setError("Presidio temporairement indisponible. Texte non anonymisé.");
|
||||
}
|
||||
|
||||
// 🔍 CONSOLE.LOG FINAL DES MAPPINGS
|
||||
console.log("📋 Mappings créés:", mappings);
|
||||
|
||||
// ÉTAPE 4 : Mettre à jour l'état global avec les mappings créés.
|
||||
setEntityMappings(mappings);
|
||||
} catch (error) {
|
||||
console.error("Erreur dans useAnonymization:", error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors de l'anonymisation avec Presidio"
|
||||
: "Une erreur inconnue est survenue."
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { anonymizeData, isProcessing };
|
||||
return {
|
||||
anonymizeData,
|
||||
isProcessing,
|
||||
};
|
||||
};
|
||||
|
||||
352
app/components/ContextMenu.tsx
Normal file
352
app/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Trash2, Check, RotateCcw } from "lucide-react";
|
||||
import { COLOR_PALETTE, type ColorOption } from "../config/colorPalette";
|
||||
// import { EntityMapping } from "../config/entityLabels"; // SUPPRIMER cette ligne
|
||||
|
||||
interface ContextMenuProps {
|
||||
contextMenu: {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
selectedText: string;
|
||||
wordIndices: number[];
|
||||
};
|
||||
existingLabels: string[];
|
||||
// entityMappings: EntityMapping[]; // SUPPRIMER cette ligne
|
||||
onApplyLabel: (
|
||||
displayName: string,
|
||||
applyToAll?: boolean
|
||||
) => void;
|
||||
onApplyColor: (
|
||||
color: string,
|
||||
colorName: string,
|
||||
applyToAll?: boolean
|
||||
) => void;
|
||||
onRemoveLabel: (applyToAll?: boolean) => void;
|
||||
getCurrentColor: (selectedText: string) => string;
|
||||
}
|
||||
|
||||
const colorOptions: ColorOption[] = COLOR_PALETTE;
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
contextMenu,
|
||||
existingLabels,
|
||||
// entityMappings, // SUPPRIMER cette ligne
|
||||
onApplyLabel,
|
||||
onApplyColor,
|
||||
onRemoveLabel,
|
||||
getCurrentColor,
|
||||
}) => {
|
||||
const [customLabel, setCustomLabel] = useState("");
|
||||
const [showNewLabelInput, setShowNewLabelInput] = useState(false);
|
||||
const [showColorPalette, setShowColorPalette] = useState(false);
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fonction corrigée pour le bouton +
|
||||
const handleNewLabelClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Bouton + cliqué - Ouverture du champ de saisie");
|
||||
setShowNewLabelInput(true);
|
||||
setShowColorPalette(false);
|
||||
};
|
||||
|
||||
const handleApplyCustomLabel = (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (customLabel.trim()) {
|
||||
console.log(
|
||||
"Application du label personnalisé:",
|
||||
customLabel.trim(),
|
||||
"À toutes les occurrences:",
|
||||
applyToAll
|
||||
);
|
||||
onApplyLabel(customLabel.trim(), applyToAll); // CORRIGER: 2 paramètres seulement
|
||||
setCustomLabel("");
|
||||
setShowNewLabelInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Modifier la fonction handleCancelNewLabel pour accepter les deux types d'événements
|
||||
const handleCancelNewLabel = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Annulation du nouveau label");
|
||||
setShowNewLabelInput(false);
|
||||
setCustomLabel("");
|
||||
};
|
||||
|
||||
// Fonction pour empêcher la propagation des événements
|
||||
const handleMenuClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Auto-focus sur l'input quand il apparaît
|
||||
useEffect(() => {
|
||||
if (showNewLabelInput && inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [showNewLabelInput]);
|
||||
|
||||
if (!contextMenu.visible) return null;
|
||||
|
||||
// Calcul du positionnement pour s'assurer que le menu reste visible
|
||||
const calculatePosition = () => {
|
||||
const menuWidth = Math.max(600, contextMenu.selectedText.length * 8 + 400); // Largeur dynamique basée sur le texte
|
||||
const menuHeight = 60; // Hauteur fixe pour une seule ligne
|
||||
const padding = 10;
|
||||
|
||||
let x = contextMenu.x;
|
||||
let y = contextMenu.y;
|
||||
|
||||
// Ajuster X pour rester dans la fenêtre
|
||||
if (x + menuWidth / 2 > window.innerWidth - padding) {
|
||||
x = window.innerWidth - menuWidth / 2 - padding;
|
||||
}
|
||||
if (x - menuWidth / 2 < padding) {
|
||||
x = menuWidth / 2 + padding;
|
||||
}
|
||||
|
||||
// Ajuster Y pour rester dans la fenêtre
|
||||
if (y + menuHeight > window.innerHeight - padding) {
|
||||
y = contextMenu.y - menuHeight - 20; // Afficher au-dessus
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const position = calculatePosition();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-context-menu
|
||||
className="fixed z-50 bg-white border border-gray-300 rounded-md"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transform: "translate(-50%, -10px)",
|
||||
minWidth: "fit-content",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={handleMenuClick}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Une seule ligne avec tous les contrôles */}
|
||||
<div className="flex items-center px-2 py-1 space-x-2">
|
||||
{/* Texte sélectionné complet */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="text-xs text-gray-800 bg-gray-50 px-2 py-1 rounded font-mono border">
|
||||
{contextMenu.selectedText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Labels existants */}
|
||||
{existingLabels.length > 0 && (
|
||||
<>
|
||||
<div className="flex-shrink-0">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.target.value) {
|
||||
const selectedDisplayName = e.target.value; // displayName
|
||||
// CORRECTION: Plus besoin de chercher entity_type !
|
||||
onApplyLabel(selectedDisplayName, applyToAll);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Chosi
|
||||
</option>
|
||||
{existingLabels.map((label) => (
|
||||
<option key={label} value={label}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Nouveau label */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
{!showNewLabelInput ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewLabelClick}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="px-1 py-1 text-xs text-green-600 border border-green-300 rounded hover:bg-green-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
title="Ajouter un nouveau label"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={customLabel}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setCustomLabel(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleApplyCustomLabel();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancelNewLabel(e);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
className="text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 w-20"
|
||||
placeholder="Label"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyCustomLabel}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
disabled={!customLabel.trim()}
|
||||
className="px-1 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
title="Appliquer le label"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelNewLabel}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="px-1 py-1 text-gray-500 hover:text-gray-700 transition-colors focus:outline-none focus:ring-1 focus:ring-gray-500"
|
||||
title="Annuler"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Sélecteur de couleur */}
|
||||
<div className="flex-shrink-0 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-5 h-5 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
|
||||
style={{
|
||||
backgroundColor: getCurrentColor(contextMenu.selectedText),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowColorPalette(!showColorPalette);
|
||||
setShowNewLabelInput(false);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="Couleur actuelle du label"
|
||||
/>
|
||||
|
||||
{showColorPalette && (
|
||||
<div className="flex items-center space-x-1 bg-gray-50 p-1 rounded border absolute z-10 mt-1 left-0">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApplyColor(color.value, color.name, applyToAll);
|
||||
setShowColorPalette(false);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-4 h-4 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Bouton supprimer */}
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveLabel(applyToAll);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="px-1 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-red-500"
|
||||
title="Supprimer le label"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Case à cocher "Toutes les occurrences" */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="applyToAll"
|
||||
checked={applyToAll}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setApplyToAll(e.target.checked);
|
||||
console.log(
|
||||
"Appliquer à toutes les occurrences:",
|
||||
e.target.checked
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor="applyToAll"
|
||||
className="text-xs text-gray-700 cursor-pointer select-none whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setApplyToAll(!applyToAll);
|
||||
}}
|
||||
>
|
||||
Toutes les occurences
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,23 @@
|
||||
import { generateAnonymizedText } from "@/app/utils/generateAnonymizedText";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
|
||||
interface DownloadActionsProps {
|
||||
outputText: string;
|
||||
entityMappings?: EntityMapping[];
|
||||
}
|
||||
|
||||
export const useDownloadActions = ({ outputText }: DownloadActionsProps) => {
|
||||
export const useDownloadActions = ({
|
||||
outputText,
|
||||
entityMappings = [],
|
||||
}: DownloadActionsProps) => {
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(outputText);
|
||||
const anonymizedText = generateAnonymizedText(outputText, entityMappings);
|
||||
navigator.clipboard.writeText(anonymizedText);
|
||||
};
|
||||
|
||||
const downloadText = () => {
|
||||
const blob = new Blob([outputText], { type: "text/plain" });
|
||||
const anonymizedText = generateAnonymizedText(outputText, entityMappings);
|
||||
const blob = new Blob([anonymizedText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -20,4 +29,4 @@ export const useDownloadActions = ({ outputText }: DownloadActionsProps) => {
|
||||
};
|
||||
|
||||
return { copyToClipboard, downloadText };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,14 +8,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
import { EntityMapping } from "../config/entityLabels";
|
||||
|
||||
interface EntityMappingTableProps {
|
||||
mappings: EntityMapping[];
|
||||
@@ -24,65 +17,72 @@ interface EntityMappingTableProps {
|
||||
export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
|
||||
Tableau de mapping des entités
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium text-[#092727]">
|
||||
Entités détectées
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-500 text-center py-4 text-sm">
|
||||
Aucune entité sensible détectée dans le texte.
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
Aucune entité détectée dans le document.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Créer un compteur pour chaque type d'entité
|
||||
const entityCounts: { [key: string]: number } = {};
|
||||
const mappingsWithNumbers = mappings.map((mapping) => {
|
||||
const entityType = mapping.entity_type;
|
||||
entityCounts[entityType] = (entityCounts[entityType] || 0) + 1;
|
||||
return {
|
||||
...mapping,
|
||||
entityNumber: entityCounts[entityType],
|
||||
displayName: mapping.displayName || mapping.replacementValue || `[${entityType}]`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
|
||||
Tableau de mapping des entités ({mappings.length} entité
|
||||
{mappings.length > 1 ? "s" : ""} anonymisée
|
||||
{mappings.length > 1 ? "s" : ""})
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium text-[#092727]">
|
||||
Entités détectées ({mappings.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:px-6">
|
||||
<CardContent>
|
||||
{/* Version mobile : Cards empilées */}
|
||||
<div className="block sm:hidden space-y-4">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div className="sm:hidden space-y-4">
|
||||
{mappingsWithNumbers.map((mapping, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
className="border rounded-lg p-4 bg-white shadow-sm"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Type d'entité
|
||||
</span>
|
||||
<div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e] text-xs"
|
||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
|
||||
>
|
||||
{mapping.entityType}
|
||||
{mapping.displayName}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||
Valeur originale
|
||||
Texte détecté
|
||||
</span>
|
||||
<div className="font-mono text-xs bg-red-50 text-red-700 p-2 rounded border break-all">
|
||||
{mapping.originalValue}
|
||||
{mapping.text}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||
Valeur anonymisée
|
||||
Identifiant
|
||||
</span>
|
||||
<div className="font-mono text-xs bg-green-50 text-green-700 p-2 rounded border break-all">
|
||||
{mapping.anonymizedValue}
|
||||
{mapping.displayName} #{mapping.entityNumber}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,29 +100,29 @@ export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
|
||||
Type d'entité
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
||||
Valeur originale
|
||||
Texte détecté
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
||||
Valeur anonymisée
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[100px]">
|
||||
Identifiant
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mappings.map((mapping, index) => (
|
||||
{mappingsWithNumbers.map((mapping, index) => (
|
||||
<TableRow key={index} className="hover:bg-gray-50">
|
||||
<TableCell className="py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
|
||||
>
|
||||
{mapping.entityType}
|
||||
{mapping.displayName}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm bg-red-50 text-red-700 py-4 max-w-[200px] break-all">
|
||||
{mapping.originalValue}
|
||||
{mapping.text}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4 max-w-[200px] break-all">
|
||||
{mapping.anonymizedValue}
|
||||
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4">
|
||||
{mapping.displayName} #{mapping.entityNumber}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
interface FileHandlerProps {
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
setSourceText: (text: string) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setIsLoadingFile?: (loading: boolean) => void; // Ajouter cette propriété
|
||||
setIsLoadingFile: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useFileHandler = ({
|
||||
setUploadedFile,
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setError,
|
||||
setIsLoadingFile,
|
||||
}: FileHandlerProps) => {
|
||||
@@ -22,12 +20,10 @@ export const useFileHandler = ({
|
||||
setUploadedFile(file);
|
||||
setError(null);
|
||||
setSourceText("");
|
||||
setFileContent("");
|
||||
|
||||
if (file.type === "text/plain") {
|
||||
try {
|
||||
const text = await file.text();
|
||||
setFileContent(text);
|
||||
setSourceText(text);
|
||||
} catch {
|
||||
setError("Erreur lors de la lecture du fichier texte");
|
||||
@@ -49,11 +45,11 @@ export const useFileHandler = ({
|
||||
if (!response.ok) {
|
||||
// ✅ Récupérer le message d'erreur détaillé du serveur
|
||||
let errorMessage = `Erreur HTTP: ${response.status}`;
|
||||
|
||||
|
||||
try {
|
||||
const responseText = await response.text();
|
||||
console.log("🔍 Réponse brute du serveur:", responseText);
|
||||
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(responseText);
|
||||
if (errorData.error) {
|
||||
@@ -63,12 +59,14 @@ export const useFileHandler = ({
|
||||
} catch (jsonError) {
|
||||
console.error("❌ Erreur parsing JSON:", jsonError);
|
||||
console.error("❌ Réponse non-JSON:", responseText);
|
||||
errorMessage = `Erreur ${response.status}: ${responseText || 'Réponse invalide du serveur'}`;
|
||||
errorMessage = `Erreur ${response.status}: ${
|
||||
responseText || "Réponse invalide du serveur"
|
||||
}`;
|
||||
}
|
||||
} catch (readError) {
|
||||
console.error("❌ Impossible de lire la réponse:", readError);
|
||||
}
|
||||
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
@@ -86,7 +84,6 @@ export const useFileHandler = ({
|
||||
);
|
||||
}
|
||||
|
||||
setFileContent(extractedText);
|
||||
setSourceText(extractedText);
|
||||
} catch (error) {
|
||||
console.error("Erreur PDF:", error);
|
||||
@@ -96,7 +93,6 @@ export const useFileHandler = ({
|
||||
: "Erreur lors de la lecture du fichier PDF"
|
||||
);
|
||||
setUploadedFile(null);
|
||||
setFileContent("");
|
||||
setSourceText("");
|
||||
} finally {
|
||||
// Désactiver le loader une fois terminé
|
||||
|
||||
@@ -11,14 +11,9 @@ import { SupportedDataTypes } from "./SupportedDataTypes";
|
||||
import { AnonymizationInterface } from "./AnonymizationInterface";
|
||||
import { highlightEntities } from "../utils/highlightEntities";
|
||||
import { useState } from "react";
|
||||
import { EntityMapping } from "../config/entityLabels"; // Importer l'interface unifiée
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
// Supprimer l'interface locale EntityMapping (lignes 15-21)
|
||||
|
||||
interface FileUploadComponentProps {
|
||||
uploadedFile: File | null;
|
||||
@@ -26,7 +21,6 @@ interface FileUploadComponentProps {
|
||||
sourceText: string;
|
||||
setSourceText: (text: string) => void;
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
onAnonymize?: () => void;
|
||||
isProcessing?: boolean;
|
||||
canAnonymize?: boolean;
|
||||
@@ -37,7 +31,7 @@ interface FileUploadComponentProps {
|
||||
downloadText?: () => void;
|
||||
isExampleLoaded?: boolean;
|
||||
setIsExampleLoaded?: (loaded: boolean) => void;
|
||||
entityMappings?: EntityMapping[]; // Ajouter cette prop
|
||||
entityMappings?: EntityMapping[];
|
||||
}
|
||||
|
||||
export const FileUploadComponent = ({
|
||||
@@ -46,7 +40,6 @@ export const FileUploadComponent = ({
|
||||
sourceText,
|
||||
setSourceText,
|
||||
setUploadedFile,
|
||||
setFileContent,
|
||||
onAnonymize,
|
||||
isProcessing = false,
|
||||
canAnonymize = false,
|
||||
@@ -56,7 +49,7 @@ export const FileUploadComponent = ({
|
||||
copyToClipboard,
|
||||
downloadText,
|
||||
setIsExampleLoaded,
|
||||
entityMappings, // Ajouter cette prop ici
|
||||
entityMappings,
|
||||
}: FileUploadComponentProps) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -224,8 +217,8 @@ export const FileUploadComponent = ({
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4 max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
<div className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap break-words overflow-wrap-anywhere leading-relaxed">
|
||||
{highlightEntities(
|
||||
outputText || "Aucun contenu à afficher",
|
||||
entityMappings
|
||||
sourceText || "Aucun contenu à afficher", // Utiliser sourceText au lieu de outputText
|
||||
entityMappings || [] // Fournir un tableau vide par défaut
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,8 +286,8 @@ export const FileUploadComponent = ({
|
||||
{/* Boutons d'action - Responsive mobile */}
|
||||
{canAnonymize && !isLoadingFile && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
{/* Bouton Anonymiser en premier */}
|
||||
{onAnonymize && (
|
||||
{/* Bouton Anonymiser - seulement si pas encore anonymisé */}
|
||||
{onAnonymize && !outputText && (
|
||||
<button
|
||||
onClick={onAnonymize}
|
||||
disabled={isProcessing}
|
||||
@@ -326,7 +319,7 @@ export const FileUploadComponent = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bouton Recommencer */}
|
||||
{/* Bouton Recommencer - toujours visible */}
|
||||
{onRestart && (
|
||||
<button
|
||||
onClick={onRestart}
|
||||
@@ -399,7 +392,6 @@ export const FileUploadComponent = ({
|
||||
<span>Commencez à taper du texte, ou </span>
|
||||
<SampleTextComponent
|
||||
setSourceText={setSourceText}
|
||||
setFileContent={setFileContent}
|
||||
setUploadedFile={setUploadedFile}
|
||||
setIsExampleLoaded={setIsExampleLoaded}
|
||||
variant="link"
|
||||
|
||||
28
app/components/InstructionsPanel.tsx
Normal file
28
app/components/InstructionsPanel.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
export const InstructionsPanel: React.FC = () => {
|
||||
return (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-2">Instructions d'utilisation :</p>
|
||||
<ul className="space-y-1 text-blue-700">
|
||||
<li>• Survolez les mots pour les mettre en évidence</li>
|
||||
<li>
|
||||
• Cliquez pour sélectionner un mot, Crtl + clic pour plusieurs
|
||||
mots
|
||||
</li>
|
||||
<li>• Faites clic droit pour ouvrir le menu contextuel</li>
|
||||
<li>• Modifiez les labels et couleurs selon vos besoins</li>
|
||||
<li>
|
||||
• Utilisez "Toutes les occurrences" pour appliquer à
|
||||
tous les mots similaires
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
app/components/InteractiveTextEditor.tsx
Normal file
123
app/components/InteractiveTextEditor.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
import { useTextParsing } from "./hooks/useTextParsing";
|
||||
import { useContextMenu } from "./hooks/useContextMenu";
|
||||
import { useColorMapping } from "./hooks/useColorMapping";
|
||||
import { TextDisplay } from "./TextDisplay";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { InstructionsPanel } from "./InstructionsPanel";
|
||||
|
||||
interface InteractiveTextEditorProps {
|
||||
text: string;
|
||||
entityMappings: EntityMapping[];
|
||||
onUpdateMapping: (
|
||||
originalValue: string,
|
||||
newLabel: string,
|
||||
entityType: string,
|
||||
applyToAllOccurrences?: boolean,
|
||||
customColor?: string // Ajouter ce paramètre
|
||||
) => void;
|
||||
onRemoveMapping?: (originalValue: string) => void;
|
||||
}
|
||||
|
||||
export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
|
||||
text,
|
||||
entityMappings,
|
||||
onUpdateMapping,
|
||||
onRemoveMapping,
|
||||
}) => {
|
||||
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set());
|
||||
const [hoveredWord, setHoveredWord] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { words } = useTextParsing(text, entityMappings);
|
||||
const { getCurrentColor } = useColorMapping(entityMappings); // CORRECTION: Passer entityMappings
|
||||
const {
|
||||
contextMenu,
|
||||
showContextMenu,
|
||||
applyLabel,
|
||||
applyColorDirectly,
|
||||
removeLabel,
|
||||
getExistingLabels,
|
||||
} = useContextMenu({
|
||||
entityMappings,
|
||||
words, // NOUVEAU: passer les mots
|
||||
onUpdateMapping,
|
||||
onRemoveMapping,
|
||||
getCurrentColor,
|
||||
setSelectedWords,
|
||||
});
|
||||
|
||||
const handleWordClick = useCallback(
|
||||
(index: number, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
setSelectedWords((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
setSelectedWords(new Set([index]));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
if (selectedWords.size === 0) return;
|
||||
|
||||
const selectedText = Array.from(selectedWords)
|
||||
.map((index) => {
|
||||
const word = words[index];
|
||||
return word?.isEntity ? word.text : word?.text;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
showContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
selectedText,
|
||||
wordIndices: Array.from(selectedWords),
|
||||
});
|
||||
},
|
||||
[selectedWords, words, showContextMenu]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<InstructionsPanel />
|
||||
|
||||
<TextDisplay
|
||||
words={words}
|
||||
text={text}
|
||||
selectedWords={selectedWords}
|
||||
hoveredWord={hoveredWord}
|
||||
onWordClick={handleWordClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onWordHover={setHoveredWord}
|
||||
/>
|
||||
|
||||
{contextMenu.visible && (
|
||||
<ContextMenu
|
||||
contextMenu={contextMenu}
|
||||
existingLabels={getExistingLabels()}
|
||||
// entityMappings={entityMappings} // SUPPRIMER cette ligne
|
||||
onApplyLabel={applyLabel}
|
||||
onApplyColor={applyColorDirectly}
|
||||
onRemoveLabel={removeLabel}
|
||||
getCurrentColor={getCurrentColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,203 @@
|
||||
import { Copy, Download, AlertTriangle } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { Copy, Download } from "lucide-react";
|
||||
import { InteractiveTextEditor } from "./InteractiveTextEditor";
|
||||
import { isValidEntityBoundary } from "@/app/utils/entityBoundary";
|
||||
import { EntityMapping } from "@/app/config/entityLabels"; // Importer l'interface unifiée
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
// Supprimer l'interface locale et utiliser celle de entityLabels.ts
|
||||
|
||||
interface ResultPreviewComponentProps {
|
||||
outputText: string;
|
||||
sourceText: string;
|
||||
copyToClipboard: () => void;
|
||||
downloadText: () => void;
|
||||
highlightEntities: (text: string, mappings?: EntityMapping[]) => ReactNode;
|
||||
entityMappings?: EntityMapping[];
|
||||
onMappingsUpdate?: (mappings: EntityMapping[]) => void;
|
||||
}
|
||||
|
||||
export const ResultPreviewComponent = ({
|
||||
outputText,
|
||||
sourceText,
|
||||
copyToClipboard,
|
||||
downloadText,
|
||||
highlightEntities,
|
||||
entityMappings,
|
||||
entityMappings = [],
|
||||
onMappingsUpdate,
|
||||
}: ResultPreviewComponentProps) => {
|
||||
// SUPPRIMER cette ligne
|
||||
// const { mappings, updateMapping, removeMappingByValueWithOptions } = useEntityMappings(entityMappings);
|
||||
|
||||
// Utiliser directement entityMappings du parent
|
||||
const handleUpdateMapping = (
|
||||
originalValue: string,
|
||||
newLabel: string,
|
||||
entityType: string,
|
||||
applyToAllOccurrences: boolean = false,
|
||||
customColor?: string,
|
||||
wordStart?: number,
|
||||
wordEnd?: number
|
||||
) => {
|
||||
// Créer les nouveaux mappings directement
|
||||
const filteredMappings = entityMappings.filter(
|
||||
(mapping) => mapping.text !== originalValue
|
||||
);
|
||||
|
||||
const newMappings: EntityMapping[] = [];
|
||||
|
||||
if (applyToAllOccurrences) {
|
||||
// Appliquer à toutes les occurrences
|
||||
let searchIndex = 0;
|
||||
while (true) {
|
||||
const foundIndex = sourceText.indexOf(originalValue, searchIndex);
|
||||
if (foundIndex === -1) break;
|
||||
|
||||
if (isValidEntityBoundary(foundIndex, sourceText, originalValue)) {
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: foundIndex,
|
||||
end: foundIndex + originalValue.length,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
});
|
||||
}
|
||||
searchIndex = foundIndex + 1;
|
||||
}
|
||||
} else {
|
||||
// CORRECTION: Utiliser wordStart/wordEnd pour cibler le mapping exact
|
||||
if (wordStart !== undefined && wordEnd !== undefined) {
|
||||
// Chercher le mapping exact avec les coordonnées précises
|
||||
const targetMapping = entityMappings.find(
|
||||
(mapping) => mapping.start === wordStart && mapping.end === wordEnd
|
||||
);
|
||||
|
||||
if (targetMapping) {
|
||||
// Mettre à jour le mapping existant spécifique
|
||||
const updatedMappings = entityMappings.map((m) => {
|
||||
if (m.start === wordStart && m.end === wordEnd) {
|
||||
return {
|
||||
...m,
|
||||
entity_type: entityType,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
onMappingsUpdate?.(updatedMappings);
|
||||
return;
|
||||
} else {
|
||||
// Créer un nouveau mapping aux coordonnées précises
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: wordStart,
|
||||
end: wordEnd,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: logique existante si pas de coordonnées précises
|
||||
const existingMapping = entityMappings.find(
|
||||
(mapping) => mapping.text === originalValue
|
||||
);
|
||||
|
||||
if (existingMapping) {
|
||||
const updatedMappings = entityMappings.map((m) => {
|
||||
if (
|
||||
m.start === existingMapping.start &&
|
||||
m.end === existingMapping.end
|
||||
) {
|
||||
return {
|
||||
...m,
|
||||
entity_type: entityType,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
onMappingsUpdate?.(updatedMappings);
|
||||
return;
|
||||
} else {
|
||||
const foundIndex = sourceText.indexOf(originalValue);
|
||||
if (
|
||||
foundIndex !== -1 &&
|
||||
isValidEntityBoundary(foundIndex, sourceText, originalValue)
|
||||
) {
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: foundIndex,
|
||||
end: foundIndex + originalValue.length,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notifier le parent avec les nouveaux mappings
|
||||
const allMappings = [...filteredMappings, ...newMappings];
|
||||
const uniqueMappings = allMappings.filter(
|
||||
(mapping, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(m) => m.start === mapping.start && m.end === mapping.end
|
||||
)
|
||||
);
|
||||
|
||||
onMappingsUpdate?.(uniqueMappings.sort((a, b) => a.start - b.start));
|
||||
};
|
||||
|
||||
// NOUVELLE FONCTION: Gestion de la suppression avec applyToAll
|
||||
const handleRemoveMapping = (
|
||||
originalValue: string,
|
||||
applyToAll: boolean = false
|
||||
) => {
|
||||
console.log("handleRemoveMapping appelé:", {
|
||||
originalValue,
|
||||
applyToAll,
|
||||
});
|
||||
|
||||
// Notifier le parent avec les nouveaux mappings
|
||||
if (onMappingsUpdate) {
|
||||
const filteredMappings = entityMappings.filter(
|
||||
(mapping: EntityMapping) => {
|
||||
if (applyToAll) {
|
||||
// Supprimer toutes les occurrences
|
||||
return mapping.text !== originalValue;
|
||||
} else {
|
||||
// Supprimer seulement la première occurrence
|
||||
const firstOccurrenceIndex = entityMappings.findIndex(
|
||||
(m: EntityMapping) => m.text === originalValue
|
||||
);
|
||||
const currentIndex = entityMappings.indexOf(mapping);
|
||||
return !(
|
||||
mapping.text === originalValue &&
|
||||
currentIndex === firstOccurrenceIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMappingsUpdate(
|
||||
filteredMappings.sort(
|
||||
(a: EntityMapping, b: EntityMapping) => a.start - b.start
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!outputText) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#f7ab6e] border-opacity-30 pb-2">
|
||||
<h3 className="text-lg font-medium text-[#092727]">
|
||||
Document anonymisé
|
||||
Document anonymisé (Mode interactif)
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -56,21 +223,12 @@ export const ResultPreviewComponent = ({
|
||||
|
||||
<div className="border border-[#f7ab6e] border-opacity-30 rounded-lg bg-white min-h-[400px] flex flex-col">
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<div className="h-full min-h-[300px] text-[#092727] whitespace-pre-wrap overflow-y-auto">
|
||||
<div className="leading-relaxed">
|
||||
{highlightEntities(outputText, entityMappings)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#f7ab6e] border-opacity-30">
|
||||
<div className="flex items-start gap-2 p-2 bg-[#f7ab6e] bg-opacity-10 rounded-md">
|
||||
<AlertTriangle className="h-4 w-4 text-[#f7ab6e] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-[#092727]">
|
||||
Vérifiez le résultat pour vous assurer que toutes les informations
|
||||
privées sont supprimées et éviter une divulgation accidentelle.
|
||||
</p>
|
||||
</div>
|
||||
<InteractiveTextEditor
|
||||
text={sourceText}
|
||||
entityMappings={entityMappings} // Utiliser entityMappings du parent au lieu de mappings
|
||||
onUpdateMapping={handleUpdateMapping}
|
||||
onRemoveMapping={handleRemoveMapping}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
interface SampleTextComponentProps {
|
||||
setSourceText: (text: string) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
setIsExampleLoaded?: (loaded: boolean) => void;
|
||||
variant?: "button" | "link"; // Nouvelle prop
|
||||
variant?: "button" | "link";
|
||||
}
|
||||
|
||||
export const SampleTextComponent = ({
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setUploadedFile,
|
||||
setIsExampleLoaded,
|
||||
variant = "button",
|
||||
@@ -26,16 +24,15 @@ Le contrat de prestation signé le 3 janvier 2024 prévoyait un montant de 75 00
|
||||
- M. Pieter Van Der Berg (consultant IT, email: p.vanderberg@itconsult.be)
|
||||
|
||||
**Données sensibles :**
|
||||
Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L’incident du 28 février 2024 a exposé les données personnelles stockées sur l’adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek.
|
||||
Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L'incident du 28 février 2024 a exposé les données personnelles stockées sur l'adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek.
|
||||
|
||||
|
||||
Coordonnées bancaires : BE43 0017 5555 5557 (CBC Banque)
|
||||
TVA intracommunautaire : BE0987.654.321`;
|
||||
setSourceText(sampleText);
|
||||
setFileContent(sampleText);
|
||||
setUploadedFile(null);
|
||||
if (setIsExampleLoaded) {
|
||||
setIsExampleLoaded(true); // NOUVEAU - Marquer qu'un exemple est chargé
|
||||
setIsExampleLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
108
app/components/TextDisplay.tsx
Normal file
108
app/components/TextDisplay.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { generateColorFromName } from "@/app/config/colorPalette";
|
||||
import { Word } from "./hooks/useTextParsing";
|
||||
|
||||
interface TextDisplayProps {
|
||||
words: Word[];
|
||||
text: string;
|
||||
selectedWords: Set<number>;
|
||||
hoveredWord: number | null;
|
||||
onWordClick: (index: number, event: React.MouseEvent) => void;
|
||||
onContextMenu: (event: React.MouseEvent) => void;
|
||||
onWordHover: (index: number | null) => void;
|
||||
}
|
||||
|
||||
export const TextDisplay: React.FC<TextDisplayProps> = ({
|
||||
words,
|
||||
text,
|
||||
selectedWords,
|
||||
hoveredWord,
|
||||
onWordClick,
|
||||
onContextMenu,
|
||||
onWordHover,
|
||||
}) => {
|
||||
const renderWord = (word: Word, index: number) => {
|
||||
const isSelected = selectedWords.has(index);
|
||||
const isHovered = hoveredWord === index;
|
||||
|
||||
let className =
|
||||
"inline-block cursor-pointer transition-all duration-200 px-1 py-0.5 rounded-sm ";
|
||||
let backgroundColor = "transparent";
|
||||
|
||||
if (word.isEntity) {
|
||||
// Couleur personnalisée ou générée - Niveau 200
|
||||
if (word.mapping?.customColor) {
|
||||
backgroundColor = word.mapping.customColor;
|
||||
} else if (word.mapping?.displayName) {
|
||||
// Utiliser generateColorFromName pour la cohérence
|
||||
backgroundColor = generateColorFromName(word.mapping.displayName).value;
|
||||
} else if (word.entityType) {
|
||||
backgroundColor = generateColorFromName(word.entityType).value;
|
||||
} else {
|
||||
// Couleur par défaut si aucune information disponible
|
||||
backgroundColor = generateColorFromName("default").value;
|
||||
}
|
||||
|
||||
// Utiliser la classe CSS appropriée
|
||||
if (word.mapping?.displayName) {
|
||||
const colorClass = generateColorFromName(word.mapping.displayName);
|
||||
className += `${colorClass.bgClass} ${colorClass.textClass} border `;
|
||||
} else if (word.entityType) {
|
||||
const colorClass = generateColorFromName(word.entityType);
|
||||
className += `${colorClass.bgClass} ${colorClass.textClass} border `;
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion du survol et sélection - Couleurs claires
|
||||
if (isSelected) {
|
||||
className += "ring-2 ring-blue-400 ";
|
||||
} else if (isHovered) {
|
||||
if (!word.isEntity) {
|
||||
className += "bg-gray-200 ";
|
||||
backgroundColor = "#E5E7EB"; // gray-200
|
||||
}
|
||||
}
|
||||
|
||||
className += "brightness-95 ";
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={className}
|
||||
style={{
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
onMouseEnter={() => onWordHover(index)}
|
||||
onMouseLeave={() => onWordHover(null)}
|
||||
onClick={(e) => onWordClick(index, e)}
|
||||
onContextMenu={onContextMenu}
|
||||
title={
|
||||
word.isEntity
|
||||
? `Entité: ${word.entityType} (Original: ${word.text})`
|
||||
: "Cliquez pour sélectionner"
|
||||
}
|
||||
>
|
||||
{word.displayText}{" "}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white border border-gray-200 rounded-lg min-h-[300px] leading-relaxed text-sm">
|
||||
<div className="whitespace-pre-wrap">
|
||||
{words.map((word, index) => {
|
||||
const nextWord = words[index + 1];
|
||||
const spaceBetween = nextWord
|
||||
? text.slice(word.end, nextWord.start)
|
||||
: text.slice(word.end);
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{renderWord(word, index)}
|
||||
<span>{spaceBetween}</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
app/components/hooks/useColorMapping.ts
Normal file
61
app/components/hooks/useColorMapping.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
generateColorFromName,
|
||||
type ColorOption,
|
||||
} from "../../config/colorPalette";
|
||||
|
||||
export const useColorMapping = (entityMappings: EntityMapping[]) => {
|
||||
const colorOptions: ColorOption[] = COLOR_PALETTE;
|
||||
|
||||
const tailwindToHex = useMemo(() => {
|
||||
const mapping: Record<string, string> = {};
|
||||
COLOR_PALETTE.forEach((color) => {
|
||||
mapping[color.bgClass] = color.value;
|
||||
});
|
||||
return mapping;
|
||||
}, []);
|
||||
|
||||
// CORRECTION: Fonction qui prend un texte et retourne la couleur
|
||||
const getCurrentColor = useCallback(
|
||||
(selectedText: string): string => {
|
||||
if (!selectedText || !entityMappings) {
|
||||
return COLOR_PALETTE[0].value;
|
||||
}
|
||||
|
||||
// Chercher le mapping correspondant au texte sélectionné
|
||||
const mapping = entityMappings.find((m) => m.text === selectedText);
|
||||
|
||||
if (mapping?.customColor) {
|
||||
return mapping.customColor;
|
||||
}
|
||||
|
||||
if (mapping?.displayName) {
|
||||
return generateColorFromName(mapping.displayName).value;
|
||||
}
|
||||
|
||||
if (mapping?.entity_type) {
|
||||
return generateColorFromName(mapping.entity_type).value;
|
||||
}
|
||||
|
||||
// Générer une couleur basée sur le texte
|
||||
return generateColorFromName(selectedText).value;
|
||||
},
|
||||
[entityMappings]
|
||||
);
|
||||
|
||||
const getColorByText = useCallback(
|
||||
(selectedText: string) => {
|
||||
return getCurrentColor(selectedText);
|
||||
},
|
||||
[getCurrentColor]
|
||||
);
|
||||
|
||||
return {
|
||||
colorOptions,
|
||||
tailwindToHex,
|
||||
getCurrentColor,
|
||||
getColorByText,
|
||||
};
|
||||
};
|
||||
207
app/components/hooks/useContextMenu.ts
Normal file
207
app/components/hooks/useContextMenu.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
import { Word } from "./useTextParsing"; // AJOUTER cet import
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
selectedText: string;
|
||||
wordIndices: number[];
|
||||
}
|
||||
|
||||
interface UseContextMenuProps {
|
||||
entityMappings: EntityMapping[];
|
||||
words: Word[]; // Maintenant le type Word est reconnu
|
||||
onUpdateMapping: (
|
||||
originalValue: string,
|
||||
newLabel: string,
|
||||
entityType: string,
|
||||
applyToAll?: boolean,
|
||||
customColor?: string,
|
||||
wordStart?: number,
|
||||
wordEnd?: number
|
||||
) => void;
|
||||
onRemoveMapping?: (originalValue: string, applyToAll?: boolean) => void;
|
||||
getCurrentColor: (selectedText: string) => string;
|
||||
setSelectedWords: (words: Set<number>) => void;
|
||||
}
|
||||
|
||||
export const useContextMenu = ({
|
||||
entityMappings,
|
||||
words, // Paramètre ajouté
|
||||
onUpdateMapping,
|
||||
onRemoveMapping,
|
||||
getCurrentColor,
|
||||
setSelectedWords,
|
||||
}: UseContextMenuProps) => {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
selectedText: "",
|
||||
wordIndices: [],
|
||||
});
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu((prev) => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
const showContextMenu = useCallback(
|
||||
(menuData: Omit<ContextMenuState, "visible">) => {
|
||||
setContextMenu({ ...menuData, visible: true });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getExistingLabels = useCallback(() => {
|
||||
const uniqueLabels = new Set<string>();
|
||||
entityMappings.forEach((mapping) => {
|
||||
uniqueLabels.add(mapping.displayName || mapping.entity_type); // Utiliser displayName
|
||||
});
|
||||
return Array.from(uniqueLabels).sort();
|
||||
}, [entityMappings]);
|
||||
|
||||
// CORRECTION: Accepter displayName comme premier paramètre
|
||||
const applyLabel = useCallback(
|
||||
(displayName: string, applyToAll?: boolean) => {
|
||||
if (!contextMenu.selectedText) return;
|
||||
|
||||
const originalText = contextMenu.selectedText;
|
||||
const firstWordIndex = contextMenu.wordIndices[0];
|
||||
|
||||
// Calculer les vraies coordonnées start/end du mot cliqué
|
||||
const clickedWord = words[firstWordIndex];
|
||||
const wordStart = clickedWord?.start;
|
||||
const wordEnd = clickedWord?.end;
|
||||
|
||||
const existingMapping = entityMappings.find(
|
||||
(m) => m.text === originalText
|
||||
);
|
||||
const entityType =
|
||||
existingMapping?.entity_type ||
|
||||
displayName.replace(/[\[\]]/g, "").toUpperCase();
|
||||
|
||||
onUpdateMapping(
|
||||
originalText,
|
||||
displayName,
|
||||
entityType,
|
||||
applyToAll,
|
||||
undefined, // customColor
|
||||
wordStart, // vraies coordonnées start
|
||||
wordEnd // vraies coordonnées end
|
||||
);
|
||||
|
||||
setSelectedWords(new Set());
|
||||
closeContextMenu();
|
||||
},
|
||||
[
|
||||
contextMenu,
|
||||
words, // NOUVEAU
|
||||
entityMappings,
|
||||
onUpdateMapping,
|
||||
closeContextMenu,
|
||||
setSelectedWords,
|
||||
]
|
||||
);
|
||||
|
||||
// CORRECTION: Accepter applyToAll comme paramètre
|
||||
const applyColorDirectly = useCallback(
|
||||
(color: string, colorName: string, applyToAll?: boolean) => {
|
||||
if (!contextMenu.selectedText) return;
|
||||
|
||||
const existingMapping = entityMappings.find(
|
||||
(mapping) => mapping.text === contextMenu.selectedText
|
||||
);
|
||||
|
||||
console.log("useContextMenu - applyColorDirectly:", {
|
||||
color,
|
||||
colorName,
|
||||
applyToAll,
|
||||
existingMapping,
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
onUpdateMapping(
|
||||
contextMenu.selectedText,
|
||||
existingMapping.displayName || existingMapping.entity_type, // Utiliser displayName
|
||||
existingMapping.entity_type,
|
||||
applyToAll,
|
||||
color
|
||||
);
|
||||
} else {
|
||||
onUpdateMapping(
|
||||
contextMenu.selectedText,
|
||||
"CUSTOM_LABEL",
|
||||
"CUSTOM_LABEL",
|
||||
applyToAll,
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedWords(new Set());
|
||||
closeContextMenu();
|
||||
},
|
||||
[
|
||||
contextMenu.selectedText,
|
||||
entityMappings, // Ajouter cette dépendance
|
||||
onUpdateMapping,
|
||||
closeContextMenu,
|
||||
setSelectedWords,
|
||||
]
|
||||
);
|
||||
|
||||
// CORRECTION: Accepter applyToAll comme paramètre
|
||||
const removeLabel = useCallback(
|
||||
(applyToAll?: boolean) => {
|
||||
if (!contextMenu.selectedText || !onRemoveMapping) return;
|
||||
|
||||
console.log("useContextMenu - removeLabel:", {
|
||||
selectedText: contextMenu.selectedText,
|
||||
applyToAll,
|
||||
});
|
||||
|
||||
onRemoveMapping(contextMenu.selectedText, applyToAll);
|
||||
setSelectedWords(new Set());
|
||||
closeContextMenu();
|
||||
},
|
||||
[
|
||||
contextMenu.selectedText,
|
||||
onRemoveMapping,
|
||||
closeContextMenu,
|
||||
setSelectedWords,
|
||||
]
|
||||
);
|
||||
|
||||
// Gestion des clics en dehors du menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (contextMenu.visible) {
|
||||
const target = event.target as Element;
|
||||
const contextMenuElement = document.querySelector(
|
||||
"[data-context-menu]"
|
||||
);
|
||||
|
||||
if (contextMenuElement && !contextMenuElement.contains(target)) {
|
||||
setTimeout(() => {
|
||||
closeContextMenu();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [contextMenu.visible, closeContextMenu]);
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
showContextMenu,
|
||||
closeContextMenu,
|
||||
applyLabel,
|
||||
applyColorDirectly,
|
||||
removeLabel,
|
||||
getExistingLabels,
|
||||
getCurrentColor,
|
||||
};
|
||||
};
|
||||
98
app/components/hooks/useTextParsing.ts
Normal file
98
app/components/hooks/useTextParsing.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
|
||||
export interface Word {
|
||||
text: string;
|
||||
displayText: string;
|
||||
start: number;
|
||||
end: number;
|
||||
isEntity: boolean;
|
||||
entityType?: string;
|
||||
entityIndex?: number;
|
||||
mapping?: EntityMapping;
|
||||
}
|
||||
|
||||
export const useTextParsing = (
|
||||
text: string,
|
||||
entityMappings: EntityMapping[]
|
||||
) => {
|
||||
const words = useMemo((): Word[] => {
|
||||
const segments: Word[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
const sortedMappings = [...entityMappings].sort(
|
||||
(a, b) => a.start - b.start // CORRECTION: utiliser 'start' au lieu de 'startIndex'
|
||||
);
|
||||
|
||||
sortedMappings.forEach((mapping, mappingIndex) => {
|
||||
if (currentIndex < mapping.start) {
|
||||
// CORRECTION: utiliser 'start'
|
||||
const beforeText = text.slice(currentIndex, mapping.start);
|
||||
const beforeWords = beforeText.split(/\s+/).filter(Boolean);
|
||||
|
||||
beforeWords.forEach((word) => {
|
||||
const wordStart = text.indexOf(word, currentIndex);
|
||||
const wordEnd = wordStart + word.length;
|
||||
|
||||
segments.push({
|
||||
text: word,
|
||||
displayText: word,
|
||||
start: wordStart,
|
||||
end: wordEnd,
|
||||
isEntity: false,
|
||||
});
|
||||
|
||||
currentIndex = wordEnd;
|
||||
});
|
||||
}
|
||||
|
||||
// Utiliser displayName au lieu de entity_type
|
||||
// Ligne 45 - Ajouter du debug
|
||||
console.log("useTextParsing - mapping:", {
|
||||
text: mapping.text,
|
||||
displayName: mapping.displayName,
|
||||
entity_type: mapping.entity_type,
|
||||
});
|
||||
|
||||
const anonymizedText =
|
||||
mapping.displayName || `[${mapping.entity_type.toUpperCase()}]`;
|
||||
|
||||
segments.push({
|
||||
text: mapping.text,
|
||||
displayText: anonymizedText,
|
||||
start: mapping.start,
|
||||
end: mapping.end,
|
||||
isEntity: true,
|
||||
entityType: mapping.entity_type,
|
||||
entityIndex: mappingIndex,
|
||||
mapping: mapping,
|
||||
});
|
||||
|
||||
currentIndex = mapping.end; // CORRECTION: utiliser 'end'
|
||||
});
|
||||
|
||||
if (currentIndex < text.length) {
|
||||
const remainingText = text.slice(currentIndex);
|
||||
const remainingWords = remainingText.split(/\s+/).filter(Boolean);
|
||||
|
||||
remainingWords.forEach((word) => {
|
||||
const wordStart = text.indexOf(word, currentIndex);
|
||||
const wordEnd = wordStart + word.length;
|
||||
|
||||
segments.push({
|
||||
text: word,
|
||||
displayText: word,
|
||||
start: wordStart,
|
||||
end: wordEnd,
|
||||
isEntity: false,
|
||||
});
|
||||
|
||||
currentIndex = wordEnd;
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}, [text, entityMappings]);
|
||||
|
||||
return { words };
|
||||
};
|
||||
Reference in New Issue
Block a user