mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Document nodes with long unbroken text (like separator lines with ====) were bleeding outside their boundaries. Fixed by: - Setting fixed width (280px) on container instead of just maxWidth - Using break-all word breaking for description text - Adding webkit line clamping to limit description to 3 lines - Using text-overflow: ellipsis with overflow: hidden
283 lines
7.9 KiB
TypeScript
283 lines
7.9 KiB
TypeScript
/**
|
||
* DocumentNode - Custom React Flow node for displaying markdown document information.
|
||
*
|
||
* Renders a card-like node showing document metadata:
|
||
* - Title prominently at top
|
||
* - Stats row: line count, word count, file size
|
||
* - Optional description (truncated with ellipsis)
|
||
*
|
||
* Styled with theme colors and supports selection/hover states.
|
||
*/
|
||
|
||
import React, { memo, useMemo } from 'react';
|
||
import { Handle, Position, NodeProps } from 'reactflow';
|
||
import { FileText, Hash, AlignLeft, HardDrive, AlertTriangle, FileWarning } from 'lucide-react';
|
||
import type { Theme } from '../../types';
|
||
import type { DocumentNodeData } from './graphDataBuilder';
|
||
|
||
/**
|
||
* Extended node data including theme and search state for styling
|
||
*/
|
||
export interface DocumentNodeProps extends NodeProps<
|
||
DocumentNodeData & {
|
||
theme: Theme;
|
||
searchActive?: boolean;
|
||
searchMatch?: boolean;
|
||
brokenLinks?: string[];
|
||
isLargeFile?: boolean;
|
||
/** Dynamic character limit for description/preview (from settings) */
|
||
previewCharLimit?: number;
|
||
}
|
||
> {
|
||
// Props come from React Flow - data, selected, etc.
|
||
}
|
||
|
||
/**
|
||
* Maximum characters for title before truncation
|
||
*/
|
||
const MAX_TITLE_LENGTH = 40;
|
||
|
||
/**
|
||
* Default maximum characters for description/preview before truncation
|
||
* This can be overridden via the previewCharLimit setting
|
||
*/
|
||
const DEFAULT_PREVIEW_CHAR_LIMIT = 100;
|
||
|
||
/**
|
||
* Truncate text with ellipsis if exceeding max length
|
||
*/
|
||
function truncateText(text: string, maxLength: number): string {
|
||
if (text.length <= maxLength) return text;
|
||
return text.slice(0, maxLength).trim() + '...';
|
||
}
|
||
|
||
/**
|
||
* Custom React Flow node for rendering markdown documents in the graph
|
||
*/
|
||
export const DocumentNode = memo(function DocumentNode({ data, selected }: DocumentNodeProps) {
|
||
const {
|
||
title,
|
||
lineCount,
|
||
wordCount,
|
||
size,
|
||
description,
|
||
contentPreview,
|
||
filePath,
|
||
theme,
|
||
searchActive,
|
||
searchMatch,
|
||
brokenLinks,
|
||
isLargeFile,
|
||
previewCharLimit,
|
||
} = data;
|
||
|
||
// Check if this document has broken links
|
||
const hasBrokenLinks = brokenLinks && brokenLinks.length > 0;
|
||
|
||
// Determine if this node should be dimmed (search active but not matching)
|
||
const isDimmed = searchActive && !searchMatch;
|
||
// Determine if this node should be highlighted (search active and matching)
|
||
const isHighlighted = searchActive && searchMatch;
|
||
|
||
// Memoize styles to prevent unnecessary recalculations
|
||
const containerStyle = useMemo(
|
||
() => ({
|
||
backgroundColor: theme.colors.bgActivity,
|
||
borderColor: isHighlighted
|
||
? theme.colors.accent
|
||
: selected
|
||
? theme.colors.accent
|
||
: theme.colors.border,
|
||
borderWidth: isHighlighted ? 2 : selected ? 2 : 1,
|
||
borderStyle: 'solid' as const,
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
minWidth: 200,
|
||
maxWidth: 280,
|
||
width: 280,
|
||
overflow: 'hidden' as const,
|
||
boxShadow: isHighlighted
|
||
? `0 0 0 3px ${theme.colors.accent}40, 0 4px 12px ${theme.colors.accentDim}`
|
||
: selected
|
||
? `0 4px 12px ${theme.colors.accentDim}`
|
||
: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||
transition: 'all 0.2s ease',
|
||
cursor: 'pointer',
|
||
opacity: isDimmed ? 0.35 : 1,
|
||
filter: isDimmed ? 'grayscale(50%)' : 'none',
|
||
}),
|
||
[theme.colors, selected, isDimmed, isHighlighted]
|
||
);
|
||
|
||
const titleStyle = useMemo(
|
||
() => ({
|
||
color: theme.colors.textMain,
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
marginBottom: 8,
|
||
lineHeight: 1.3,
|
||
overflow: 'hidden' as const,
|
||
textOverflow: 'ellipsis' as const,
|
||
whiteSpace: 'nowrap' as const,
|
||
}),
|
||
[theme.colors.textMain]
|
||
);
|
||
|
||
// Use description (frontmatter) or fall back to contentPreview (plaintext)
|
||
const previewText = description || contentPreview;
|
||
|
||
const statsRowStyle = useMemo(
|
||
() => ({
|
||
display: 'flex',
|
||
gap: 12,
|
||
marginBottom: previewText ? 8 : 0,
|
||
}),
|
||
[previewText]
|
||
);
|
||
|
||
const statItemStyle = useMemo(
|
||
() => ({
|
||
display: 'flex',
|
||
alignItems: 'center' as const,
|
||
gap: 4,
|
||
color: theme.colors.textDim,
|
||
fontSize: 11,
|
||
}),
|
||
[theme.colors.textDim]
|
||
);
|
||
|
||
const descriptionStyle = useMemo(
|
||
() => ({
|
||
color: theme.colors.textDim,
|
||
fontSize: 12,
|
||
lineHeight: 1.4,
|
||
opacity: 0.85,
|
||
overflow: 'hidden' as const,
|
||
textOverflow: 'ellipsis' as const,
|
||
wordBreak: 'break-all' as const,
|
||
overflowWrap: 'anywhere' as const,
|
||
display: '-webkit-box' as const,
|
||
WebkitLineClamp: 3,
|
||
WebkitBoxOrient: 'vertical' as const,
|
||
}),
|
||
[theme.colors.textDim]
|
||
);
|
||
|
||
const handleStyle = useMemo(
|
||
() => ({
|
||
backgroundColor: theme.colors.accent,
|
||
borderColor: theme.colors.bgActivity,
|
||
width: 8,
|
||
height: 8,
|
||
}),
|
||
[theme.colors]
|
||
);
|
||
|
||
// Warning icon style for broken links indicator
|
||
const warningIconStyle = useMemo(
|
||
() => ({
|
||
color: '#f59e0b', // Amber/warning color
|
||
flexShrink: 0,
|
||
}),
|
||
[]
|
||
);
|
||
|
||
// Large file indicator style (blue info color)
|
||
const largeFileIconStyle = useMemo(
|
||
() => ({
|
||
color: '#3b82f6', // Blue info color
|
||
flexShrink: 0,
|
||
}),
|
||
[]
|
||
);
|
||
|
||
// Truncate title if too long
|
||
const displayTitle = truncateText(title, MAX_TITLE_LENGTH);
|
||
const isTitleTruncated = title.length > MAX_TITLE_LENGTH;
|
||
|
||
// Use dynamic character limit from settings, falling back to default
|
||
const charLimit = previewCharLimit ?? DEFAULT_PREVIEW_CHAR_LIMIT;
|
||
|
||
// Truncate preview text (description or content preview) if too long
|
||
const displayPreview = previewText ? truncateText(previewText, charLimit) : null;
|
||
const isPreviewTruncated = previewText ? previewText.length > charLimit : false;
|
||
|
||
// Build large file tooltip text
|
||
const largeFileTooltip = isLargeFile
|
||
? `\n\nℹ️ Large file (>1MB) - some links may not be detected`
|
||
: '';
|
||
|
||
// Build broken links tooltip text
|
||
const brokenLinksTooltip = hasBrokenLinks
|
||
? `\n\n⚠️ Broken links (${brokenLinks.length}):\n${brokenLinks.map((link) => ` • ${link}`).join('\n')}`
|
||
: '';
|
||
|
||
// Build tooltip: show full title if truncated, always show file path, plus status info
|
||
const tooltipText = isTitleTruncated
|
||
? `${title}\n\n${filePath}${largeFileTooltip}${brokenLinksTooltip}`
|
||
: `${filePath}${largeFileTooltip}${brokenLinksTooltip}`;
|
||
|
||
return (
|
||
<div
|
||
className={`document-node${isHighlighted ? ' search-highlight' : ''}`}
|
||
style={containerStyle}
|
||
title={tooltipText}
|
||
>
|
||
{/* Input handle (for incoming edges) - not connectable by user drag */}
|
||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||
|
||
{/* Title with document icon and optional warning icon */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
|
||
<FileText size={14} style={{ color: theme.colors.accent, flexShrink: 0 }} />
|
||
<div style={titleStyle}>{displayTitle}</div>
|
||
{hasBrokenLinks && (
|
||
<span
|
||
data-testid="broken-links-warning"
|
||
aria-label={`${brokenLinks.length} broken link${brokenLinks.length > 1 ? 's' : ''}`}
|
||
style={warningIconStyle}
|
||
>
|
||
<AlertTriangle size={14} />
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Stats row: lines, words, size, and large file indicator */}
|
||
<div style={statsRowStyle}>
|
||
<div style={statItemStyle} title={`${lineCount} lines`}>
|
||
<Hash size={10} />
|
||
<span>{lineCount}</span>
|
||
</div>
|
||
<div style={statItemStyle} title={`${wordCount} words`}>
|
||
<AlignLeft size={10} />
|
||
<span>{wordCount}</span>
|
||
</div>
|
||
<div style={statItemStyle} title={size}>
|
||
<HardDrive size={10} />
|
||
<span>{size}</span>
|
||
</div>
|
||
{isLargeFile && (
|
||
<span
|
||
data-testid="large-file-indicator"
|
||
style={largeFileIconStyle}
|
||
title="Large file (>1MB) - content truncated for parsing"
|
||
>
|
||
<FileWarning size={12} />
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Optional preview text (description or content preview) - shows tooltip with full text when truncated */}
|
||
{displayPreview && (
|
||
<div style={descriptionStyle} title={isPreviewTruncated ? previewText : undefined}>
|
||
{displayPreview}
|
||
</div>
|
||
)}
|
||
|
||
{/* Output handle (for outgoing edges) - not connectable by user drag */}
|
||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export default DocumentNode;
|