GuardPII / public /elements /UserMessage.jsx
Antigravity Bot
Blur Badges
ee1fafd
Raw
History Blame Contribute Delete
18.3 kB
import React, { useState } from 'react';
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Eye, EyeClosed } from "lucide-react"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Button } from "@/components/ui/button"
import React from "react"
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table"
function parseTable(lines, startIndex) {
const headerLine = lines[startIndex]
const separatorLine = lines[startIndex + 1]
if (!separatorLine || !separatorLine.match(/^\s*\|[-\s|]+\|\s*$/)) {
return null
}
const headers = headerLine
.split("|")
.slice(1, -1)
.map(h => h.trim())
const rows = []
let i = startIndex + 2
while (i < lines.length && lines[i].includes("|")) {
const cells = lines[i]
.split("|")
.slice(1, -1)
.map(c => c.trim())
rows.push(cells)
i++
}
return {
table: { headers, rows },
nextIndex: i,
}
}
export function Markdown({ children, dic, show }) {
const [localMessage, setLocalMessage] = useState(children);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [selectionData, setSelectionData] = useState(null);
const lines = children.split("\n")
const elements = []
let i = 0
const handleMouseUp = () => {
const selection = window.getSelection();
// 1. Check basic validity
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
// If just a click (collapsed), we assume menu shouldn't open or should close
// But we don't want to close immediately if clicking inside the menu, handled by DropdownMenu logic
return;
}
// 2. Expand Selection to Word Boundaries
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
// Only support single text node selection for simplicity to map back to data-index
if (anchorNode !== focusNode || anchorNode.nodeType !== 3) {
setOpen(false);
return;
}
const textContent = anchorNode.textContent;
let start = Math.min(selection.anchorOffset, selection.focusOffset);
let end = Math.max(selection.anchorOffset, selection.focusOffset);
// Expand left
while (start > 0 && /\S/.test(textContent[start - 1])) {
start--;
}
// Expand right
while (end < textContent.length && /\S/.test(textContent[end])) {
end++;
}
// Update visual selection
const newRange = document.createRange();
newRange.setStart(anchorNode, start);
newRange.setEnd(anchorNode, end);
selection.removeAllRanges();
selection.addRange(newRange);
const newText = textContent.slice(start, end);
if (!newText.trim()) return;
// 3. Validation Logic
// Check 1: Less than 4 words
const words = newText.split(/\s+/);
if (words.length >= 4) {
setOpen(false);
return;
}
// Check 2: Does not contain highlighted word (brackets)
if (newText.includes('[') || newText.includes(']')) {
setOpen(false);
return;
}
// Check 3: Check if selection is inside a Badge
const parentElement = anchorNode.parentElement;
if (parentElement.closest('.bg-pii')) {
setOpen(false);
return;
}
// Identify Part Index
const partIndex = parseInt(parentElement.getAttribute('data-index'), 10);
// if (isNaN(partIndex)) {
// setOpen(false);
// return;
// }
const rect = newRange.getBoundingClientRect();
setPosition({
x: rect.left,
y: rect.bottom
});
setSelectionData({
partIndex,
start,
end,
text: newText
});
setOpen(true);
};
const handleAnonymize = () => {
if (!selectionData) return;
const { partIndex, start, end, text } = selectionData;
// Split by brackets to reconstruct parts array as seen in render
const parts = localMessage.split(/(\[[^\]]+\])/g);
if (parts[partIndex] === undefined) return;
const originalPart = parts[partIndex];
const newPart = originalPart.substring(0, start) + `[ANONYME_${text}]` + originalPart.substring(end);
parts[partIndex] = newPart;
const newMessage = parts.join('');
setLocalMessage(newMessage);
setOpen(false);
// Clear selection
window.getSelection().removeAllRanges();
};
while (i < lines.length) {
const line = lines[i]
// HEADERS
const headerMatch = line.match(/^(#{1,6})\s+(.*)$/)
if (headerMatch) {
const level = headerMatch[1].length
const text = headerMatch[2]
const Tag = `h${level}`
elements.push(
<Tag key={i} className="mt-4 mb-2 font-semibold">
{renderInline(text, dic, show)}
</Tag>
)
i++
continue
}
// TABLE
if (line.trim().startsWith("|")) {
const parsed = parseTable(lines, i)
if (parsed) {
const { headers, rows } = parsed.table
elements.push(
<Table key={i} className="my-4">
<TableHeader>
<TableRow>
{headers.map((h, idx) => (
<TableHead key={idx}>
{renderInline(h, dic, show)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, rIdx) => (
<TableRow key={rIdx}>
{row.map((cell, cIdx) => (
<TableCell key={cIdx}>
{renderInline(cell, dic, show)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
i = parsed.nextIndex
continue
}
}
// PARAGRAPH
if (line.trim() !== "") {
elements.push(
<p key={i} className="mb-2">
{renderInline(line, dic, show)}
</p>
)
}
i++
}
return (
<div onMouseUp={handleMouseUp}>
<div className="relative bg-accent rounded-3xl px-5 py-2.5 user-message flex-grow-0">
<p className="leading-[1.75] whitespace-pre-wrap">{elements}</p>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
top: position.y,
left: position.x,
width: '1px',
height: '1px',
visibility: 'hidden',
pointerEvents: 'none'
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={handleAnonymize}>
Anonymiser
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
function parseInlineText(text) {
const tokens = []
let i = 0
while (i < text.length) {
// CODE `
if (text[i] === "`") {
const end = text.indexOf("`", i + 1)
if (end !== -1) {
tokens.push({ type: "code", value: text.slice(i + 1, end) })
i = end + 1
continue
}
}
// BOLD **
if (text.slice(i, i + 2) === "**") {
const end = text.indexOf("**", i + 2)
if (end !== -1) {
tokens.push({ type: "bold", value: text.slice(i + 2, end) })
i = end + 2
continue
}
}
// ITALIC *
if (text[i] === "*") {
const end = text.indexOf("*", i + 1)
if (end !== -1) {
tokens.push({ type: "italic", value: text.slice(i + 1, end) })
i = end + 1
continue
}
}
// TEXT
let j = i
while (
j < text.length &&
text[j] !== "`" &&
text[j] !== "*"
) {
j++
}
tokens.push({ type: "text", value: text.slice(i, j) })
i = j
}
return tokens
}
function renderInline(text, dic, show) {
const parts = text.split(/(\[[^\]]+\])/g)
return parts.map((part, index) => {
// PII
const match = part.match(/^\[(\w+)_(.*?)\]$/)
if (match && dic[match[2]]) {
return show ? (
<Badge
key={index}
className="bg-pii text-blue-800 rounded-full px-2 py-1 mx-1"
>
{dic[match[2]].value}
</Badge>
) : (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Badge
key={index}
className="bg-pii text-blue-800 rounded-full px-2 py-1 mx-1 blur-sm"
>
{dic[match[2]].value}
</Badge>
{/* <span className="bg-pii px-1 rounded cursor-pointer blur-sm">
{/* {part.toUpperCase()} */}
{/* {dic[match[2]].value} */}
{/* </span> */}
</TooltipTrigger>
<TooltipContent>
<p>{dic[match[2]].value}</p>
</TooltipContent>
</Tooltip >
)
}
// TEXTE NORMAL → inline markdown
const tokens = parseInlineText(part)
return (
<span key={index} data-index={index}>
{tokens.map((t, i) => {
if (t.type === "bold") {
return <strong key={i}>{t.value}</strong>
}
if (t.type === "italic") {
return <em key={i}>{t.value}</em>
}
if (t.type === "code") {
return (
<code
key={i}
className="px-1 py-0.5 rounded bg-muted text-sm"
>
{t.value}
</code>
)
}
return <span key={i}>{t.value}</span>
})}
</span>
)
})
}
const Badge = ({ children, className }) => (
<span className={` py-0.5 transition-colors text-main-700 ${className}`}>
{children}
</span>
);
function MessageWithHighlight({ message, dic, show }) {
if (!message) return null;
const [localMessage, setLocalMessage] = useState(message);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [selectionData, setSelectionData] = useState(null);
const handleMouseUp = () => {
const selection = window.getSelection();
// 1. Check basic validity
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
// If just a click (collapsed), we assume menu shouldn't open or should close
// But we don't want to close immediately if clicking inside the menu, handled by DropdownMenu logic
return;
}
// 2. Expand Selection to Word Boundaries
const anchorNode = selection.anchorNode;
const focusNode = selection.focusNode;
// Only support single text node selection for simplicity to map back to data-index
if (anchorNode !== focusNode || anchorNode.nodeType !== 3) {
setOpen(false);
return;
}
const textContent = anchorNode.textContent;
let start = Math.min(selection.anchorOffset, selection.focusOffset);
let end = Math.max(selection.anchorOffset, selection.focusOffset);
// Expand left
while (start > 0 && /\S/.test(textContent[start - 1])) {
start--;
}
// Expand right
while (end < textContent.length && /\S/.test(textContent[end])) {
end++;
}
// Update visual selection
const newRange = document.createRange();
newRange.setStart(anchorNode, start);
newRange.setEnd(anchorNode, end);
selection.removeAllRanges();
selection.addRange(newRange);
const newText = textContent.slice(start, end);
if (!newText.trim()) return;
// 3. Validation Logic
// Check 1: Less than 4 words
const words = newText.split(/\s+/);
if (words.length >= 4) {
setOpen(false);
return;
}
// Check 2: Does not contain highlighted word (brackets)
if (newText.includes('[') || newText.includes(']')) {
setOpen(false);
return;
}
// Check 3: Check if selection is inside a Badge
const parentElement = anchorNode.parentElement;
if (parentElement.closest('.bg-pii')) {
setOpen(false);
return;
}
// Identify Part Index
const partIndex = parseInt(parentElement.getAttribute('data-index'), 10);
if (isNaN(partIndex)) {
setOpen(false);
return;
}
const rect = newRange.getBoundingClientRect();
setPosition({
x: rect.left,
y: rect.bottom
});
setSelectionData({
partIndex,
start,
end,
text: newText
});
setOpen(true);
};
const handleAnonymize = () => {
if (!selectionData) return;
const { partIndex, start, end, text } = selectionData;
// Split by brackets to reconstruct parts array as seen in render
const parts = localMessage.split(/(\[[^\]]+\])/g);
if (parts[partIndex] === undefined) return;
const originalPart = parts[partIndex];
const newPart = originalPart.substring(0, start) + `[ANONYME_${text}]` + originalPart.substring(end);
parts[partIndex] = newPart;
const newMessage = parts.join('');
setLocalMessage(newMessage);
setOpen(false);
// Clear selection
window.getSelection().removeAllRanges();
};
// Split by brackets, keeping the brackets in the split result for now
// Regex: split by [content]
const parts = localMessage.split(/(\[[^\]]+\])/g);
return (
<div className='flex flex-row-reverse w-full'>
<div onMouseUp={handleMouseUp} className="relative bg-accent rounded-3xl px-5 py-2.5 user-message flex-grow-0">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
top: position.y,
left: position.x,
width: '1px',
height: '1px',
visibility: 'hidden',
pointerEvents: 'none'
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={handleAnonymize}>
Anonymiser
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export default function UserMessage() {
const [show, setShow] = useState(true);
return (
<div className="flex flex-col gap-2">
{props.author == 'user' ? (
<div className='flex flex-row-reverse w-full'>
{/* <div className="relative bg-accent rounded-3xl px-5 py-2.5 user-message flex-grow-0"> */}
<Markdown dic={props.dic} show={show}>{props.message}</Markdown>
{/* </div> */}
</div>
) : (
<Markdown dic={props.dic} show={show}>{props.message}</Markdown>
)}
<div className="flex justify-end">
<Button variant="outline" size="icon" onClick={() => setShow(!show)}>
{show ? <Eye /> : <EyeClosed />}
</Button>
</div>
</div>
);
}