import { formatSize } from "@yaakapp-internal/lib/formatSize"; import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui"; import type { ReactNode } from "react"; import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import type { SniffedValue } from "./core/Editor/sniffValue"; import { showDialog } from "../lib/dialog"; import { decodeValue, largeValueActions } from "../lib/largeValue"; import { Dropdown } from "./core/Dropdown"; import { AudioViewer } from "./responseViewers/AudioViewer"; import { ImageViewer } from "./responseViewers/ImageViewer"; import { SvgViewer } from "./responseViewers/SvgViewer"; import { VideoViewer } from "./responseViewers/VideoViewer"; const PdfViewer = lazy(() => import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })), ); interface Props { /** The whole value, exactly as it reads in the document */ text: string; sniffed: SniffedValue | null; } /** * Shows a value the editor collapsed, in whatever viewer its type calls for. * * The editor only ever read the value's head, so this is where it is decoded in full โ€” on an * explicit click, behind a spinner, rather than during layout. */ export function LargeValueDialog({ text, sniffed }: Props) { const viewer = sniffed == null ? null : viewerFor(sniffed.mime); const decoded = useDecoded(text, viewer == null ? null : sniffed); // Nothing recognised it, so there is nothing to decode it into if (sniffed == null || viewer == null) { return ( {sniffed != null && ( {sniffed.label} content cannot be previewed, so it is shown as it appears in the response. )} ); } if (decoded == null) { return ( ); } if ("error" in decoded) { return ( Failed to decode this {sniffed.label} value: {decoded.error} ); } const { bytes } = decoded; switch (viewer) { case "svg": return ; case "image": return (
); case "audio": return ; case "video": return ; case "pdf": return ( }> ); case "text": return ; } } /** * Decoding megabytes is worth doing once, not on every render โ€” and the viewers below key their * blob URLs off the string, so a fresh one each time would rebuild them for nothing. */ function DecodedSvg({ bytes }: { bytes: Uint8Array }) { const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]); return ; } function DecodedText({ bytes }: { bytes: Uint8Array }) { const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]); return ; } /** * The same menu the tag in the editor carries, minus the one entry that would only reopen this. * * It lives in the dialog's title rather than above the content, which would cost a band of * whitespace the width of the dialog to hold one small button. */ function LargeValueActions({ text, sniffed }: Props) { const items = useMemo( () => largeValueActions({ value: () => text, sniffed, // The tag copies only what it hides; in here, what you see is the whole value copyText: () => text, }), [text, sniffed], ); return ( ); } type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text"; /** Which viewer a media type calls for, or null if none of them can show it. */ function viewerFor(mime: string): Viewer | null { if (mime.startsWith("image/svg")) return "svg"; if (mime.startsWith("image/")) return "image"; if (mime.startsWith("audio/")) return "audio"; if (mime.startsWith("video/")) return "video"; if (mime === "application/pdf") return "pdf"; if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text"; return null; } type Decoded = { bytes: Uint8Array } | { error: string }; /** * The value's bytes, once they exist. * * Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than * during render โ€” the dialog paints with a spinner first, instead of opening late. */ function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null { const [decoded, setDecoded] = useState(null); useEffect(() => { if (sniffed == null) return; setDecoded(null); let cancelled = false; const timer = setTimeout(() => { let result: Decoded; try { result = { bytes: decodeValue(text, sniffed) }; } catch (err) { result = { error: err instanceof Error ? err.message : String(err) }; } if (!cancelled) setDecoded(result); }); return () => { cancelled = true; clearTimeout(timer); }; }, [text, sniffed]); return decoded; } /** A copy, since a `Uint8Array` may be a view onto a larger buffer */ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.slice().buffer; } /** Characters shown at once. A page this size lays out instantly once its lines are short. */ const PAGE_CHARS = 50_000; /** Where a line is broken. The long-line cost this whole feature avoids is per line. */ const WRAP_CHARS = 120; /** * The raw text, in pages of short lines. * * What is left when nothing can render the value. Handing it to an editor whole would walk * straight back into the layout stall that collapsed it in the first place, so it is paged and * hard-wrapped instead: no line is ever long, and no page is ever big. */ function PagedText({ text, children }: { text: string; children?: ReactNode }) { const [page, setPage] = useState(0); const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS)); const from = page * PAGE_CHARS; const to = Math.min(from + PAGE_CHARS, text.length); const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]); return (
{children}
{body}
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters {pages > 1 && ( Page {page + 1} of {pages.toLocaleString()} )}
); } /** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */ function wrap(text: string): string { const lines: string[] = []; for (const line of text.split("\n")) { if (line.length <= WRAP_CHARS) { lines.push(line); continue; } for (let i = 0; i < line.length; i += WRAP_CHARS) { lines.push(line.slice(i, i + WRAP_CHARS)); } } return lines.join("\n"); } export function showLargeValueDialog({ text, sniffed }: Props) { showDialog({ id: "large-value", size: "lg", className: "h-[calc(100vh-10rem)]", // Everything in one line: the same words the tag uses, and the same menu it carries. A // separate description and a row of its own for the button cost three bands to say this. title: ( {sniffed == null ? "Hidden Value" : sniffed.label} ยท {formatSize(text.length)} ), render: () => , }); }