Spaces:
Sleeping
Sleeping
| 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> | |
| ); | |
| } | |