/**
* WikiLinkRenderer Component
*
* Renders wiki-style [[link]] syntax as clickable elements.
* Parses content for [[memory-id|preview]] patterns and renders them
* as styled clickable links that navigate to the linked memory.
*
* Requirements: 41.3, 41.4
*/
import { useCallback, useMemo, useState } from "react";
import type { MemoryPreview } from "../../utils/previewUtils";
import { KeywordHoverPreview } from "./KeywordHoverPreview";
// ============================================================================
// Types
// ============================================================================
import {
ParsedWikiLink,
parseContentWithWikiLinks,
parseWikiLinks,
} from "../../utils/wikiLinkUtils";
// ============================================================================
// Types
// ============================================================================
export type { ParsedWikiLink };
export interface WikiLinkRendererProps {
/** The content to parse and render */
content: string;
/** Callback when a wiki link is clicked - receives the memory ID */
onLinkClick?: (memoryId: string) => void;
/** Callback when a wiki link is hovered */
onLinkHover?: (link: ParsedWikiLink | null) => void;
/** Additional CSS classes */
className?: string;
/** Map of memory IDs to memory previews for hover tooltip */
memoryPreviews?: Map<string, MemoryPreview> | undefined;
/** Whether to use high contrast colors */
highContrast?: boolean | undefined;
}
// ============================================================================
// Constants
// ============================================================================
/**
* Default color for wiki links (cyan to match semantic links)
*/
const WIKI_LINK_COLOR = "#00FFFF";
/**
* High contrast color for wiki links
*/
const WIKI_LINK_COLOR_HIGH_CONTRAST = "#00E5E5";
/**
* Light mode color for wiki links (darker for visibility)
*/
const WIKI_LINK_COLOR_LIGHT = "#0066CC";
// ============================================================================
// Sub-Components
// ============================================================================
interface WikiLinkElementProps {
link: ParsedWikiLink;
onClick: ((memoryId: string) => void) | undefined;
onHover: ((link: ParsedWikiLink | null) => void) | undefined;
onMouseMove: ((link: ParsedWikiLink, position: { x: number; y: number }) => void) | undefined;
highContrast: boolean;
}
/**
* Renders a single wiki link as a clickable element.
* Requirements: 41.3, 41.4
*/
function WikiLinkElement({
link,
onClick,
onHover,
onMouseMove,
highContrast,
}: WikiLinkElementProps): React.ReactElement {
// Check if we're in light mode for color adjustments
const isLightMode =
typeof document !== "undefined" &&
document.documentElement.getAttribute("data-theme-mode") === "light";
const color = isLightMode
? WIKI_LINK_COLOR_LIGHT
: highContrast
? WIKI_LINK_COLOR_HIGH_CONTRAST
: WIKI_LINK_COLOR;
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onClick?.(link.memoryId);
},
[onClick, link.memoryId]
);
const handleMouseEnter = useCallback(() => {
onHover?.(link);
}, [onHover, link]);
const handleMouseLeave = useCallback(() => {
onHover?.(null);
}, [onHover]);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
onMouseMove?.(link, { x: e.clientX, y: e.clientY });
},
[onMouseMove, link]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.(link.memoryId);
}
},
[onClick, link.memoryId]
);
const ariaLabel = `Wiki link to memory: ${link.displayText}. Press Enter to navigate.`;
return (
<span
role="button"
tabIndex={0}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseMove={handleMouseMove}
onKeyDown={handleKeyDown}
aria-label={ariaLabel}
className="cursor-pointer transition-all duration-150 hover:brightness-125 inline-flex items-center gap-1"
style={{
color,
textDecoration: "none",
}}
data-testid="wiki-link"
>
{/* Link icon */}
<svg
className="w-3 h-3 flex-shrink-0"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6.5 11.5L4 14a2.12 2.12 0 01-3-3l2.5-2.5" />
<path d="M9.5 4.5L12 2a2.12 2.12 0 013 3l-2.5 2.5" />
<path d="M6 10l4-4" />
</svg>
{/* Link text with underline on hover */}
<span
className="hover:underline"
style={{
textDecorationColor: color,
textDecorationThickness: "1px",
textUnderlineOffset: "2px",
}}
>
{link.displayText}
</span>
</span>
);
}
// ============================================================================
// Main Component
// ============================================================================
/**
* WikiLinkRenderer - Renders wiki-style [[link]] syntax as clickable elements.
*
* Features:
* - Parses content for [[memory-id|preview]] patterns
* - Renders links as styled clickable elements with link icon
* - Supports click to navigate to linked memory
* - Shows hover preview tooltip with memory details
* - Accessible with keyboard navigation
*
* Requirements: 41.3, 41.4
*/
export function WikiLinkRenderer({
content,
onLinkClick,
onLinkHover,
className = "",
memoryPreviews,
highContrast = false,
}: WikiLinkRendererProps): React.ReactElement {
// State for hover preview tooltip
const [hoveredLink, setHoveredLink] = useState<ParsedWikiLink | null>(null);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
// Parse wiki links from content
const parsedLinks = useMemo(() => parseWikiLinks(content), [content]);
// Parse content into segments
const segments = useMemo(
() => parseContentWithWikiLinks(content, parsedLinks),
[content, parsedLinks]
);
// Handle link hover with external callback
const handleLinkHover = useCallback(
(link: ParsedWikiLink | null) => {
setHoveredLink(link);
onLinkHover?.(link);
},
[onLinkHover]
);
// Handle mouse move to track position for tooltip
const handleMouseMove = useCallback(
(_link: ParsedWikiLink, position: { x: number; y: number }) => {
setMousePosition(position);
},
[]
);
// Get memory preview for the hovered link
const hoveredMemoryPreview = useMemo(() => {
if (!hoveredLink || !memoryPreviews) {
return null;
}
return memoryPreviews.get(hoveredLink.memoryId) ?? null;
}, [hoveredLink, memoryPreviews]);
// Create array of previews for KeywordHoverPreview component
const connectedMemoryPreviews = useMemo(() => {
if (!hoveredMemoryPreview) {
return [];
}
return [hoveredMemoryPreview];
}, [hoveredMemoryPreview]);
return (
<>
<span className={className}>
{segments.map((segment, index) => {
if (segment.type === "text") {
return <span key={`text-${String(index)}`}>{segment.content}</span>;
}
// Wiki link segment
const link = segment.link;
if (!link) {
return <span key={`text-${String(index)}`}>{segment.content}</span>;
}
return (
<WikiLinkElement
key={`wikilink-${String(link.startIndex)}-${String(link.endIndex)}`}
link={link}
onClick={onLinkClick}
onHover={handleLinkHover}
onMouseMove={handleMouseMove}
highContrast={highContrast}
/>
);
})}
</span>
{/* Hover preview tooltip */}
{memoryPreviews && (
<KeywordHoverPreview
isVisible={hoveredLink !== null && connectedMemoryPreviews.length > 0}
keywordText={hoveredLink?.displayText ?? ""}
linkType="semantic"
connectedMemories={connectedMemoryPreviews}
position={mousePosition}
highContrast={highContrast}
/>
)}
</>
);
}
export default WikiLinkRenderer;