Files
Maestro/src/renderer/components/DocumentGraph/DocumentNode.tsx
Pedram Amini 13f68c15a7 fix(document-graph): truncate long text lines in document nodes
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
2026-02-01 22:59:23 -06:00

283 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;