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) => )}
);
}