diff --git a/frontend/components/JsonDisplay.tsx b/frontend/components/JsonDisplay.tsx index 79a859c..42a3ac3 100644 --- a/frontend/components/JsonDisplay.tsx +++ b/frontend/components/JsonDisplay.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { CheckIcon, ChevronDownIcon, ClipboardIcon, SearchIcon, XIcon, ArrowUpwardIcon, ArrowDownwardIcon } from './icons/material-icons-imports'; +import { CheckIcon, ChevronDownIcon, ClipboardIcon, SearchIcon, XIcon, ArrowUpwardIcon, ArrowDownwardIcon, CaseSensitiveIcon, WholeWordIcon, RegexIcon, ImageIcon } from './icons/material-icons-imports'; // Search context for highlighting and navigation interface SearchState { @@ -11,29 +11,55 @@ interface SearchState { } // Helper function to highlight search matches in text -const highlightText = (text: string, searchQuery: string, isCurrentMatch: boolean = false): React.ReactNode => { - if (!searchQuery || !text.includes(searchQuery)) { +// Helper function to highlight search matches in text +const highlightText = (text: string, searchRegex: RegExp | null): React.ReactNode => { + if (!searchRegex || !text) { return text; } - const parts = text.split(new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')); - let matchIndex = 0; - - return parts.map((part, index) => { - if (part.toLowerCase() === searchQuery.toLowerCase()) { - const currentMatch = matchIndex++; - return ( - - {part} - - ); + // Create a local regex with 'g' flag to find all matches in this string + let localRegex: RegExp; + try { + localRegex = new RegExp(searchRegex.source, searchRegex.flags.includes('g') ? searchRegex.flags : searchRegex.flags + 'g'); + } catch (e) { + return text; + } + + const matches: { start: number; end: number; text: string }[] = []; + let match; + localRegex.lastIndex = 0; + + while ((match = localRegex.exec(text)) !== null) { + matches.push({ start: match.index, end: match.index + match[0].length, text: match[0] }); + if (match[0].length === 0) localRegex.lastIndex++; // Avoid infinite loop + } + + if (matches.length === 0) return text; + + const nodes: React.ReactNode[] = []; + let lastIndex = 0; + + matches.forEach((m, index) => { + if (m.start > lastIndex) { + nodes.push(text.substring(lastIndex, m.start)); } - return part; + nodes.push( + + {m.text} + + ); + lastIndex = m.end; }); + + if (lastIndex < text.length) { + nodes.push(text.substring(lastIndex)); + } + + return <>{nodes}; }; // Component to render ObjectId with both click navigation and copy functionality @@ -129,10 +155,10 @@ const ObjectIdDisplay: React.FC<{ {/* Context Menu */} {showContextMenu && ( -
= ({ base64String, searchRegex }) => { + const [isExpanded, setIsExpanded] = useState(false); + const [previewPos, setPreviewPos] = useState<{ x: number; y: number } | null>(null); + const iconRef = useRef(null); + + const handleMouseEnter = () => { + if (iconRef.current) { + const rect = iconRef.current.getBoundingClientRect(); + const previewHeight = 220; // Approx max height + const previewWidth = 220; // Approx max width + + // Default to showing above + let top = rect.top - previewHeight - 8; + let left = rect.left; + + // If not enough space on top, show below + if (top < 10) { + top = rect.bottom + 8; + } + + // Prevent right overflow + if (left + previewWidth > window.innerWidth) { + left = window.innerWidth - previewWidth - 10; + } + + // Prevent left overflow + if (left < 10) left = 10; + + setPreviewPos({ x: left, y: top }); + } + }; + + const handleMouseLeave = () => { + setPreviewPos(null); + }; + + if (isExpanded) { + return ( + setIsExpanded(false)} + title="Click to collapse back to icon" + > + " + {searchRegex ? highlightText(base64String, searchRegex) : base64String}" + + + ); + } + + return ( + + { + setIsExpanded(true); + setPreviewPos(null); + }} + > + + + + {/* Tooltip Image Preview - Uses fixed positioning to escape container clipping */} + {previewPos && ( +
+ Preview +
+ )} +
+ ); +}; + // A single, recursive component to render all parts of the JSON object. const JsonNode: React.FC<{ nodeValue: any; @@ -182,9 +298,9 @@ const JsonNode: React.FC<{ isRoot?: boolean; // The top-level object is not collapsible onObjectIdClick?: (id: string, keyContext?: string, openInNewTab?: boolean) => void; parentKeyContext?: string; // The key of the parent, used for context in clicks - searchQuery?: string; // Search query for highlighting + searchRegex?: RegExp | null; // Regex for highlighting currentMatchIndex?: number; // Current match index for highlighting -}> = ({ nodeValue, nodeKey, isRoot = false, onObjectIdClick, parentKeyContext, searchQuery = '', currentMatchIndex = -1 }) => { +}> = ({ nodeValue, nodeKey, isRoot = false, onObjectIdClick, parentKeyContext, searchRegex = null, currentMatchIndex = -1 }) => { // All nodes are expanded by default. const [isExpanded, setIsExpanded] = useState(true); @@ -194,7 +310,7 @@ const JsonNode: React.FC<{ return ( canCollapse && setIsExpanded(!isExpanded)} className={canCollapse ? 'cursor-pointer' : ''}> {nodeKey && " - {searchQuery ? highlightText(nodeKey, searchQuery) : nodeKey}" + {searchRegex ? highlightText(nodeKey, searchRegex) : nodeKey}" } {nodeKey && :} {canCollapse && ( @@ -219,47 +335,47 @@ const JsonNode: React.FC<{ return (
{nodeKey && " - {searchQuery ? highlightText(nodeKey, searchQuery) : nodeKey}": + {searchRegex ? highlightText(nodeKey, searchRegex) : nodeKey}": } null
); } - + // BSON Types: ObjectId, Date if (typeof nodeValue === 'object' && nodeValue !== null) { if (nodeValue.$oid && Object.keys(nodeValue).length === 1 && typeof nodeValue.$oid === 'string') { const objectId = nodeValue.$oid; return (
- {nodeKey && " - {searchQuery ? highlightText(nodeKey, searchQuery) : nodeKey}": - } - - ObjectId( - - ) - + {nodeKey && " + {searchRegex ? highlightText(nodeKey, searchRegex) : nodeKey}": + } + + ObjectId( + + ) +
); } if (nodeValue.$date && Object.keys(nodeValue).length === 1) { - return ( -
- {nodeKey && " - {searchQuery ? highlightText(nodeKey, searchQuery) : nodeKey}": - } - - ISODate(" - {searchQuery ? highlightText(new Date(nodeValue.$date).toISOString(), searchQuery) : new Date(nodeValue.$date).toISOString()}") - -
- ); + return ( +
+ {nodeKey && " + {searchRegex ? highlightText(nodeKey, searchRegex) : nodeKey}": + } + + ISODate(" + {searchRegex ? highlightText(new Date(nodeValue.$date).toISOString(), searchRegex) : new Date(nodeValue.$date).toISOString()}") + +
+ ); } } @@ -269,7 +385,7 @@ const JsonNode: React.FC<{ return ( {nodeKey && " - {searchQuery ? highlightText(nodeKey, searchQuery) : nodeKey}": + {searchRegex ? highlightText(nodeKey, searchRegex) : nodeKey}": } [] @@ -283,11 +399,11 @@ const JsonNode: React.FC<{
{nodeValue.map((item, index) => (
- {index < nodeValue.length - 1 && ','} @@ -305,14 +421,14 @@ const JsonNode: React.FC<{ if (typeof nodeValue === 'object') { const keys = Object.keys(nodeValue); if (keys.length === 0) { - return ( - - {nodeKey && " - {searchQuery ? highlightText(nodeKey, searchQuery) : nodeKey}": - } - {`{}`} - - ); + return ( + + {nodeKey && " + {searchRegex ? highlightText(nodeKey, searchRegex) : nodeKey}": + } + {`{}`} + + ); } return (
@@ -322,12 +438,12 @@ const JsonNode: React.FC<{
{keys.map((key, index) => (
- {index < keys.length - 1 && ','} @@ -340,52 +456,57 @@ const JsonNode: React.FC<{
); } - + // Primitives const renderPrimitive = () => { switch (typeof nodeValue) { - case 'string': { - const isObjectIdLike = /^[0-9a-fA-F]{24}$/.test(nodeValue); - if (isObjectIdLike && onObjectIdClick) { - return ( - - - - ); - } - return " - {searchQuery ? highlightText(nodeValue, searchQuery) : nodeValue}" - ; + case 'string': { + const isObjectIdLike = /^[0-9a-fA-F]{24}$/.test(nodeValue); + if (isObjectIdLike && onObjectIdClick) { + return ( + + + + ); } - case 'number': - const numberStr = String(nodeValue); - return - {searchQuery ? highlightText(numberStr, searchQuery) : numberStr} - ; - case 'boolean': - const boolStr = String(nodeValue); - return - {searchQuery ? highlightText(boolStr, searchQuery) : boolStr} - ; - default: - const defaultStr = String(nodeValue); - return - {searchQuery ? highlightText(defaultStr, searchQuery) : defaultStr} - ; + + if (nodeValue.startsWith("data:image/png;base64,")) { + return ; + } + + return " + {searchRegex ? highlightText(nodeValue, searchRegex) : nodeValue}" + ; } + case 'number': + const numberStr = String(nodeValue); + return + {searchRegex ? highlightText(numberStr, searchRegex) : numberStr} + ; + case 'boolean': + const boolStr = String(nodeValue); + return + {searchRegex ? highlightText(boolStr, searchRegex) : boolStr} + ; + default: + const defaultStr = String(nodeValue); + return + {searchRegex ? highlightText(defaultStr, searchRegex) : defaultStr} + ; + } }; return (
- {nodeKey && " - {searchQuery ? highlightText(nodeKey, searchQuery) : nodeKey}": - } - {renderPrimitive()} + {nodeKey && " + {searchRegex ? highlightText(nodeKey, searchRegex) : nodeKey}": + } + {renderPrimitive()}
); }; @@ -398,35 +519,69 @@ interface JsonDisplayProps { const JsonDisplay: React.FC = ({ data, onObjectIdClick }) => { const [copied, setCopied] = useState(false); const [searchQuery, setSearchQuery] = useState(''); + const [matchCase, setMatchCase] = useState(false); + const [matchWholeWord, setMatchWholeWord] = useState(false); + const [useRegex, setUseRegex] = useState(false); + const [searchRegex, setSearchRegex] = useState(null); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [totalMatches, setTotalMatches] = useState(0); const [isSearchVisible, setIsSearchVisible] = useState(false); const containerRef = useRef(null); const searchInputRef = useRef(null); - // Count total matches in the JSON string - const countMatches = useCallback((query: string) => { - if (!query) return 0; - const jsonString = JSON.stringify(data, null, 2); - const matches = jsonString.match(new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi')); - return matches ? matches.length : 0; - }, [data]); - - // Update match count when search query changes + // Update regex and count matches when inputs change useEffect(() => { - const matches = countMatches(searchQuery); - setTotalMatches(matches); - if (matches === 0) { - setCurrentMatchIndex(0); - } else if (currentMatchIndex >= matches) { + if (!searchQuery) { + setSearchRegex(null); + setTotalMatches(0); setCurrentMatchIndex(0); + return; + } + + try { + let pattern = searchQuery; + let flags = 'g'; + + if (!useRegex) { + pattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + if (matchWholeWord) { + pattern = `\\b${pattern}\\b`; + } + + if (!matchCase) { + flags += 'i'; + } + + const regex = new RegExp(pattern, flags); + setSearchRegex(regex); + + const jsonString = JSON.stringify(data, null, 2); + const matches = jsonString.match(regex); + const count = matches ? matches.length : 0; + + setTotalMatches(count); + if (count === 0) { + setCurrentMatchIndex(0); + } else if (currentMatchIndex >= count) { + setCurrentMatchIndex(0); + } + } catch (e) { + setSearchRegex(null); + setTotalMatches(0); } - }, [searchQuery, countMatches, currentMatchIndex]); + }, [searchQuery, matchCase, matchWholeWord, useRegex, data]); // removed currentMatchIndex dependency to prevent loop, added data // Navigate to current match const scrollToMatch = useCallback(() => { if (!containerRef.current || totalMatches === 0) return; - + + // allow a tick for DOM to update with highlights + // but useEffect is post-render, so DOM should be ready if nodes rendered. + // However, JsonNode is recursive, might take time? No, sync render. + const matches = containerRef.current.querySelectorAll('[data-search-match]'); if (matches.length === 0) return; @@ -485,7 +640,7 @@ const JsonDisplay: React.FC = ({ data, onObjectIdClick }) => { setCopied(true); setTimeout(() => setCopied(false), 2000); }).catch(err => { - console.error("Failed to copy JSON:", err); + console.error("Failed to copy JSON:", err); }); }; @@ -534,9 +689,33 @@ const JsonDisplay: React.FC = ({ data, onObjectIdClick }) => { } }} /> +
+ + + +
+ {searchQuery && (
- + {totalMatches > 0 ? `${currentMatchIndex + 1}/${totalMatches}` : 'No matches'}
)} - +
         
-          
         
       
- +
{!isSearchVisible && (