import { useCallback, useEffect, useMemo, useRef } from "react"; import type { CloudSearchDocType, CloudSearchDocument, CloudSearchDocumentChunk, CloudSearchHit, } from "../../../api-client"; import { Button, PaneStatusBody } from "../../../components"; import { openUrl } from "../../../components/ui/external-link"; import { useShortcut } from "../../../react/input"; import { colors } from "../../../theme/colors"; import { Box, ScrollBox, StyledText, Text, TextAttributes, useUiCapabilities, type ScrollBoxRenderable, } from "../../../ui"; import { isPlainKey } from "../../../utils/keyboard"; import { wrapTextLines } from "../../../utils/text-wrap"; import { chunkAttribution, documentBodyWidth } from "./model"; import { highlightTerms, snippetMatchTerms, type SnippetSegment } from "./snippet"; import { SnippetText } from "./snippet-text"; interface DocumentLine { key: string; kind: "attribution" | "body"; text: string; segments?: SnippetSegment[]; /** Part of the chunk the hit came from. */ active: boolean; } interface DocumentLayout { lines: DocumentLine[]; matchLine: number | null; } function buildLayout( document: CloudSearchDocument, hit: CloudSearchHit, width: number, ): DocumentLayout { const terms = snippetMatchTerms(hit.snippet); const lines: DocumentLine[] = []; let matchLine: number | null = null; for (const chunk of document.chunks) { const active = chunk.chunkIndex === hit.chunkIndex; if (active && matchLine === null) matchLine = lines.length; const attribution = chunkAttribution(document.docType, chunk.metadata); if (attribution) { lines.push({ key: `${chunk.id}:attribution`, kind: "attribution", text: attribution, active, }); } const bodyLines = wrapTextLines(chunk.body ?? "", width); for (const [index, line] of bodyLines.entries()) { lines.push({ key: `${chunk.id}:${index}`, kind: "body", text: line, // Only the chunk that matched carries term highlights; marking every // occurrence in a long filing would leave nothing standing out. segments: active && terms.length > 0 ? highlightTerms(line, terms) : undefined, active, }); } lines.push({ key: `${chunk.id}:gap`, kind: "body", text: "", active: false }); } return { lines, matchLine }; } const WRAPPED_BODY_STYLE = { display: "block" }; function segmentsToStyledText(segments: readonly SnippetSegment[], color: string): StyledText { return new StyledText(segments.map((segment) => ({ text: segment.text, fg: segment.marked ? colors.warning : color, attributes: segment.marked ? TextAttributes.BOLD : TextAttributes.NONE, }))); } function AttributionText({ text, active }: { text: string; active: boolean }) { return ( {text} ); } /** * One chunk as a single paragraph the host wraps for itself. The desktop lays * boxes out on the cell grid but draws glyphs on a narrower advance, so a line * pre-wrapped to a cell count stops roughly a tenth of the pane short of the * right edge there. Only the terminal, whose cell is the glyph, can pre-wrap. */ function DocumentChunkView({ chunk, docType, terms, active, }: { chunk: CloudSearchDocumentChunk; docType: CloudSearchDocType; terms: readonly string[]; active: boolean; }) { const attribution = chunkAttribution(docType, chunk.metadata); const body = chunk.body ?? ""; const color = active ? colors.textBright : colors.text; const highlighted = active && terms.length > 0; return ( {attribution ? : null} {highlighted ? ( ) : ( {body} )} ); } function DocumentLineView({ line }: { line: DocumentLine }) { const background = line.active ? colors.panel : undefined; if (line.kind === "attribution") { return ( ); } if (line.segments) { return ( ); } return ( {line.text} ); } export function SearchDocumentView({ hit, document, loading, error, width, focused = false, }: { hit: CloudSearchHit; document: CloudSearchDocument | null; loading: boolean; error: string | null; width: number; /** j and k scroll a line, as in every other reader. */ focused?: boolean; }) { const scrollRef = useRef(null); const { nativePaneChrome } = useUiCapabilities(); const bodyWidth = documentBodyWidth(width); const terms = useMemo(() => snippetMatchTerms(hit.snippet), [hit.snippet]); const layout = useMemo( () => (document ? buildLayout(document, hit, bodyWidth) : null), [bodyWidth, document, hit], ); // Land the reader on the chunk that matched, with a little context above it. // The host wraps the same text into its own number of lines, so aim by the // share of the document above the match rather than by the counted line. useEffect(() => { const scrollBox = scrollRef.current; if (!scrollBox || layout?.matchLine == null) return; const target = nativePaneChrome && layout.lines.length > 0 ? Math.round((layout.matchLine / layout.lines.length) * scrollBox.scrollHeight) : layout.matchLine; scrollBox.scrollTo(Math.max(0, target - 2)); }, [layout, nativePaneChrome]); const scrollBy = useCallback((delta: number) => { const scrollBox = scrollRef.current; if (!scrollBox?.viewport) return; const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height); scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta)); }, []); useShortcut((event) => { if (event.defaultPrevented || event.targetEditable) return; const delta = isPlainKey(event, "j") ? 1 : isPlainKey(event, "k") ? -1 : 0; if (!delta) return; event.preventDefault(); event.stopPropagation(); scrollBy(delta); }, { enabled: focused && !!layout }); if (loading && !document) { return ( ); } if (error && !document) { // A news hit names a story that is public at its source, so a server that // will not hand over the indexed copy is not a dead end: the whole point of // opening one is to read it, and the original always can be. return ( openUrl(hit.url)} /> : undefined} /> ); } if (!document || !layout) return null; return ( // Only the left inset lives here: the scrollbar already holds the column on // the right, and padding on both sides would push the text under it. {nativePaneChrome ? document.chunks.map((chunk) => ( )) : layout.lines.map((line) => )} ); }