| "use client"; |
|
|
| import { useState, useRef, useCallback, useEffect } from "react"; |
|
|
| export default function SplitView({ paragraphs, classifications, enriched }) { |
| const leftRef = useRef(null); |
| const rightRef = useRef(null); |
| const [syncing, setSyncing] = useState(false); |
| const [hoveredIndex, setHoveredIndex] = useState(null); |
|
|
| |
| const classMap = new Map(); |
| if (classifications && classifications.length > 0) { |
| for (const c of classifications) { |
| classMap.set(c.index, c); |
| } |
| } |
|
|
| |
| const handleScroll = useCallback( |
| (source) => { |
| if (syncing) return; |
| setSyncing(true); |
|
|
| const sourceEl = source === "left" ? leftRef.current : rightRef.current; |
| const targetEl = source === "left" ? rightRef.current : leftRef.current; |
|
|
| if (sourceEl && targetEl) { |
| const ratio = |
| sourceEl.scrollTop / |
| (sourceEl.scrollHeight - sourceEl.clientHeight || 1); |
| targetEl.scrollTop = |
| ratio * (targetEl.scrollHeight - targetEl.clientHeight); |
| } |
|
|
| requestAnimationFrame(() => setSyncing(false)); |
| }, |
| [syncing] |
| ); |
|
|
| |
| const scrollToIndex = useCallback((index, panel) => { |
| const ref = panel === "left" ? leftRef : rightRef; |
| const el = ref.current?.querySelector(`[data-para-index="${index}"]`); |
| if (el) { |
| el.scrollIntoView({ behavior: "smooth", block: "center" }); |
| } |
| }, []); |
|
|
| return ( |
| <div className="flex-1 flex min-h-0"> |
| {/* Left panel: Original document view */} |
| <div |
| className="flex-1 flex flex-col border-r" |
| style={{ borderColor: "var(--border-color)" }} |
| > |
| <div |
| className="px-4 py-2 text-xs font-medium border-b flex items-center gap-2" |
| style={{ |
| background: "var(--bg-tertiary)", |
| borderColor: "var(--border-color)", |
| color: "var(--text-secondary)", |
| }} |
| > |
| <svg |
| className="w-3.5 h-3.5" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" |
| /> |
| </svg> |
| Original Document |
| </div> |
| <div |
| ref={leftRef} |
| className="split-panel px-4 py-3" |
| onScroll={() => handleScroll("left")} |
| style={{ background: "var(--bg-secondary)" }} |
| > |
| {paragraphs.map((p) => ( |
| <div |
| key={`orig-${p.index}`} |
| data-para-index={p.index} |
| className={`para-row px-3 py-2 rounded-md mb-1 transition-colors cursor-default ${ |
| hoveredIndex === p.index ? "ring-1" : "" |
| }`} |
| style={{ |
| ...(hoveredIndex === p.index |
| ? { |
| background: "var(--bg-hover)", |
| ringColor: "var(--accent)", |
| } |
| : {}), |
| }} |
| onMouseEnter={() => setHoveredIndex(p.index)} |
| onMouseLeave={() => setHoveredIndex(null)} |
| > |
| {p.text ? ( |
| <p className="text-sm leading-relaxed" style={{ color: "var(--text-primary)" }}> |
| <span |
| className="text-xs mr-2 select-none" |
| style={{ color: "var(--text-muted)" }} |
| > |
| {p.index} |
| </span> |
| {p.text} |
| </p> |
| ) : ( |
| <p className="text-sm" style={{ color: "var(--text-muted)", opacity: 0.3 }}> |
| <span className="text-xs mr-2 select-none">{p.index}</span> |
| (empty) |
| </p> |
| )} |
| {p.fontSizePt && ( |
| <span |
| className="text-xs ml-6" |
| style={{ color: "var(--text-muted)" }} |
| > |
| {p.fontSizePt}pt |
| {p.isBold ? " • bold" : ""} |
| </span> |
| )} |
| </div> |
| ))} |
| </div> |
| </div> |
| |
| {/* Right panel: Enriched view with toggle controls */} |
| <div className="flex-1 flex flex-col"> |
| <div |
| className="px-4 py-2 text-xs font-medium border-b flex items-center gap-2" |
| style={{ |
| background: "var(--bg-tertiary)", |
| borderColor: "var(--border-color)", |
| color: "var(--text-secondary)", |
| }} |
| > |
| <svg |
| className="w-3.5 h-3.5" |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={2} |
| d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" |
| /> |
| </svg> |
| Enriched Document |
| {enriched && ( |
| <span className="ml-auto text-xs" style={{ color: "var(--text-muted)" }}> |
| Click checkboxes to toggle headings |
| </span> |
| )} |
| </div> |
| <div |
| ref={rightRef} |
| className="split-panel px-4 py-3" |
| onScroll={() => handleScroll("right")} |
| style={{ background: "var(--bg-secondary)" }} |
| > |
| {!enriched ? ( |
| <div className="flex items-center justify-center h-full"> |
| <div className="text-center"> |
| <div |
| className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center" |
| style={{ background: "var(--bg-tertiary)" }} |
| > |
| <svg |
| className="w-7 h-7" |
| style={{ color: "var(--text-muted)" }} |
| fill="none" |
| stroke="currentColor" |
| viewBox="0 0 24 24" |
| > |
| <path |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| strokeWidth={1.5} |
| d="M13 10V3L4 14h7v7l9-11h-7z" |
| /> |
| </svg> |
| </div> |
| <p |
| className="text-sm font-medium mb-1" |
| style={{ color: "var(--text-secondary)" }} |
| > |
| Click "Enrich with AI" to start |
| </p> |
| <p className="text-xs" style={{ color: "var(--text-muted)" }}> |
| The AI will detect which paragraphs are headings |
| </p> |
| </div> |
| </div> |
| ) : ( |
| <EnrichedParagraphs |
| paragraphs={paragraphs} |
| classifications={classifications} |
| classMap={classMap} |
| hoveredIndex={hoveredIndex} |
| setHoveredIndex={setHoveredIndex} |
| /> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function EnrichedParagraphs({ |
| paragraphs, |
| classifications, |
| classMap, |
| hoveredIndex, |
| setHoveredIndex, |
| }) { |
| |
| |
| const [localClassifications, setLocalClassifications] = useState(classifications); |
|
|
| |
| useEffect(() => { |
| setLocalClassifications(classifications); |
| }, [classifications]); |
|
|
| const localClassMap = new Map(); |
| for (const c of localClassifications) { |
| localClassMap.set(c.index, c); |
| } |
|
|
| const toggleHeading = (index) => { |
| setLocalClassifications((prev) => |
| prev.map((c) => |
| c.index === index ? { ...c, isHeading: !c.isHeading } : c |
| ) |
| ); |
| |
| const existing = classMap.get(index); |
| if (existing) { |
| existing.isHeading = !existing.isHeading; |
| } |
| }; |
|
|
| return ( |
| <> |
| {paragraphs.map((p) => { |
| const cls = localClassMap.get(p.index); |
| const isHeading = cls?.isHeading || false; |
| |
| return ( |
| <div |
| key={`enr-${p.index}`} |
| data-para-index={p.index} |
| className={`para-row flex items-start gap-3 px-3 py-2 rounded-md mb-1 transition-colors ${ |
| isHeading ? "is-heading" : "" |
| } ${hoveredIndex === p.index ? "ring-1" : ""}`} |
| style={{ |
| ...(hoveredIndex === p.index |
| ? { |
| background: "var(--bg-hover)", |
| ringColor: "var(--accent)", |
| } |
| : {}), |
| }} |
| onMouseEnter={() => setHoveredIndex(p.index)} |
| onMouseLeave={() => setHoveredIndex(null)} |
| > |
| <input |
| type="checkbox" |
| className="heading-checkbox mt-1" |
| checked={isHeading} |
| onChange={() => toggleHeading(p.index)} |
| title={isHeading ? "Unmark as heading" : "Mark as heading"} |
| /> |
| <div className="flex-1 min-w-0"> |
| {p.text ? ( |
| <p |
| className={`leading-relaxed ${isHeading ? "font-bold" : ""}`} |
| style={{ |
| color: isHeading |
| ? "var(--accent)" |
| : "var(--text-primary)", |
| fontSize: isHeading ? "16px" : "14px", |
| }} |
| > |
| <span |
| className="text-xs mr-2 font-normal select-none" |
| style={{ color: "var(--text-muted)" }} |
| > |
| {p.index} |
| </span> |
| {p.text} |
| </p> |
| ) : ( |
| <p className="text-sm" style={{ color: "var(--text-muted)", opacity: 0.3 }}> |
| <span className="text-xs mr-2 select-none">{p.index}</span> |
| (empty) |
| </p> |
| )} |
| {isHeading && ( |
| <span |
| className="text-xs ml-6 inline-block mt-0.5" |
| style={{ color: "var(--accent)" }} |
| > |
| ✦ Heading — font size will be increased |
| </span> |
| )} |
| </div> |
| </div> |
| ); |
| })} |
| </> |
| ); |
| } |
|
|