sync: Major UI updates from private repo (Jan 16-24)
Features synced: - UI Panels Refactor: flexible pane system with ChatPane, NodePane, DimensionsPane, WorkflowsPane, ViewsPane, MapPane - Source Content Reader: content type detection + 4 formatters (transcript, book, markdown, raw) - Source Content Search: Cmd+F search in Source Reader - Feed Layout Overhaul: LeftToolbar, SplitHandle, CollapsedRail - Map Panel: promoted to first-class pane - Edge policy simplification: auto-inference + direction correction New files: - src/components/panes/* (pane system) - src/components/layout/CollapsedRail.tsx - src/components/layout/LeftToolbar.tsx - src/components/layout/SplitHandle.tsx - src/components/focus/source/* (Source Reader + formatters) - src/components/views/ViewsOverlay.tsx Updated: - ThreePanelLayout.tsx (complete refactor) - FocusPanel.tsx (Source tab integration) - API routes (dimensions GET, edges auto-inference) - Database services (nodes, edges, dimensions) Dependencies added: - react-markdown - remark-gfm Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
cea1df7f1f
commit
05523b7cb2
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { detectContentType, getContentTypeLabel } from './ContentDetector';
|
||||
import RawFormatter from './formatters/RawFormatter';
|
||||
import TranscriptFormatter from './formatters/TranscriptFormatter';
|
||||
import BookFormatter from './formatters/BookFormatter';
|
||||
import MarkdownFormatter from './formatters/MarkdownFormatter';
|
||||
import SourceSearchBar from './SourceSearchBar';
|
||||
|
||||
interface SourceReaderProps {
|
||||
content: string;
|
||||
onTextSelect?: (text: string) => void;
|
||||
highlightedText?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source Reader - Read-only formatted view of source content
|
||||
*
|
||||
* This is a pure presentation component that NEVER modifies the source data.
|
||||
* It detects content type and applies appropriate formatting for comfortable reading.
|
||||
*/
|
||||
export default function SourceReader({
|
||||
content,
|
||||
onTextSelect,
|
||||
highlightedText,
|
||||
}: SourceReaderProps) {
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchHighlight, setSearchHighlight] = useState<string | null>(null);
|
||||
const [searchMatchIndex, setSearchMatchIndex] = useState(0);
|
||||
const [scrollTrigger, setScrollTrigger] = useState(0);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Detect content type (memoized for performance)
|
||||
const contentType = useMemo(() => detectContentType(content), [content]);
|
||||
const contentTypeLabel = getContentTypeLabel(contentType);
|
||||
|
||||
// Combined highlight: search takes precedence over external highlight
|
||||
const activeHighlight = searchHighlight || highlightedText;
|
||||
|
||||
// Keyboard shortcut for Cmd+F
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
|
||||
e.preventDefault();
|
||||
setShowSearch(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
// Scroll to current match when search highlight changes
|
||||
useEffect(() => {
|
||||
if (!searchHighlight || !contentRef.current) return;
|
||||
|
||||
// Small delay to let React render the mark element
|
||||
const timer = setTimeout(() => {
|
||||
const currentMatch = contentRef.current?.querySelector('mark[data-search-match="current"]');
|
||||
if (currentMatch) {
|
||||
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchHighlight, scrollTrigger]);
|
||||
|
||||
const handleSearchClose = useCallback(() => {
|
||||
setShowSearch(false);
|
||||
setSearchHighlight(null);
|
||||
setSearchMatchIndex(0);
|
||||
}, []);
|
||||
|
||||
const handleHighlightChange = useCallback((text: string | null, matchIndex: number) => {
|
||||
setSearchHighlight(text);
|
||||
setSearchMatchIndex(matchIndex);
|
||||
// Trigger scroll even if same text (for navigating between matches)
|
||||
setScrollTrigger(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Render appropriate formatter based on content type
|
||||
const renderContent = () => {
|
||||
const highlightIndex = showSearch ? searchMatchIndex : undefined;
|
||||
switch (contentType) {
|
||||
case 'transcript':
|
||||
return <TranscriptFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
case 'book':
|
||||
case 'article':
|
||||
return <BookFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
case 'markdown':
|
||||
return <MarkdownFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
default:
|
||||
return <RawFormatter content={content} onTextSelect={onTextSelect} highlightedText={activeHighlight} highlightMatchIndex={highlightIndex} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0f0f0f',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header with content type and search */}
|
||||
{content && content.length >= 50 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#555',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
Detected: {contentTypeLabel}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
title="Search content (⌘F)"
|
||||
style={{
|
||||
background: showSearch ? '#262626' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
color: showSearch ? '#fafafa' : '#555',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '10px',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
{showSearch && content && (
|
||||
<SourceSearchBar
|
||||
content={content}
|
||||
onClose={handleSearchClose}
|
||||
onHighlightChange={handleHighlightChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Formatted content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user