656 lines
28 KiB
TypeScript
656 lines
28 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import { convertFileSrc } from "@tauri-apps/api/core"
|
||
import { openPath } from "@tauri-apps/plugin-opener"
|
||
import ReactMarkdown from "react-markdown"
|
||
import remarkGfm from "remark-gfm"
|
||
import remarkMath from "remark-math"
|
||
import rehypeKatex from "rehype-katex"
|
||
import "katex/dist/katex.min.css"
|
||
import {
|
||
FileText,
|
||
Image as ImageIcon,
|
||
Film,
|
||
Music,
|
||
FileSpreadsheet,
|
||
FileQuestion,
|
||
Code2,
|
||
ExternalLink,
|
||
RefreshCw,
|
||
Maximize2,
|
||
Minus,
|
||
Plus,
|
||
X,
|
||
} from "lucide-react"
|
||
import { useTranslation } from "react-i18next"
|
||
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist"
|
||
import { getFileSize, readFileAsBase64 } from "@/commands/fs"
|
||
import {
|
||
getFileCategory,
|
||
getCodeLanguage,
|
||
getFileExtension,
|
||
isExtractedTextPreviewFile,
|
||
} from "@/lib/file-types"
|
||
import type { FileCategory } from "@/lib/file-types"
|
||
import { getFileName, normalizePath } from "@/lib/path-utils"
|
||
import { resolveMarkdownImageSrc } from "@/lib/markdown-image-resolver"
|
||
import { transformImageEmbeds } from "@/lib/wikilink-transform"
|
||
import { detectLanguage } from "@/lib/detect-language"
|
||
import { getHtmlLang, getTextDirection } from "@/lib/language-metadata"
|
||
import { parseFrontmatter } from "@/lib/frontmatter"
|
||
import { FrontmatterPanel } from "@/components/editor/frontmatter-panel"
|
||
import { useWikiStore } from "@/stores/wiki-store"
|
||
import { MermaidDiagram, unwrapMermaidPre } from "@/components/mermaid-diagram"
|
||
import { FileHistoryButton } from "@/components/editor/file-history-panel"
|
||
|
||
interface FilePreviewProps {
|
||
filePath: string
|
||
textContent: string
|
||
}
|
||
|
||
export function FilePreview({ filePath, textContent }: FilePreviewProps) {
|
||
return <div className="relative h-full min-h-0">
|
||
<FilePreviewContent filePath={filePath} textContent={textContent} />
|
||
<FileHistoryButton filePath={filePath} currentContent={textContent} />
|
||
</div>
|
||
}
|
||
|
||
function FilePreviewContent({ filePath, textContent }: FilePreviewProps) {
|
||
const category = getFileCategory(filePath)
|
||
const fileName = getFileName(filePath)
|
||
const extension = getFileExtension(filePath)
|
||
|
||
switch (category) {
|
||
case "image":
|
||
return <ImagePreview filePath={filePath} fileName={fileName} />
|
||
case "video":
|
||
return <VideoPreview filePath={filePath} fileName={fileName} />
|
||
case "audio":
|
||
return <AudioPreview filePath={filePath} fileName={fileName} />
|
||
case "pdf":
|
||
return <PdfPreview filePath={filePath} content={textContent} />
|
||
case "code":
|
||
if (extension === "mmd" || extension === "mermaid") {
|
||
return <StandaloneMermaidPreview filePath={filePath} content={textContent} />
|
||
}
|
||
if (extension === "svg" && isAgentWorkspacePath(filePath)) {
|
||
return <ImagePreview filePath={filePath} fileName={fileName} />
|
||
}
|
||
if (extension === "html" || extension === "htm") {
|
||
return <HtmlPreview filePath={filePath} fileName={fileName} content={textContent} />
|
||
}
|
||
return <CodePreview filePath={filePath} content={textContent} />
|
||
case "data":
|
||
if (extension === "csv" || extension === "tsv") {
|
||
return <DelimitedTablePreview filePath={filePath} content={textContent} delimiter={extension === "tsv" ? "\t" : ","} />
|
||
}
|
||
return <CodePreview filePath={filePath} content={textContent} />
|
||
case "text":
|
||
return <TextPreview filePath={filePath} content={textContent} label="Text" />
|
||
case "document":
|
||
if (isExtractedTextPreviewFile(filePath)) {
|
||
return <TextPreview filePath={filePath} content={textContent} label={extractedTextLabel(filePath)} />
|
||
}
|
||
return <BinaryPlaceholder filePath={filePath} fileName={fileName} category={category} />
|
||
default:
|
||
return <BinaryPlaceholder filePath={filePath} fileName={fileName} category={category} />
|
||
}
|
||
}
|
||
|
||
function PdfPreview({ filePath, content }: { filePath: string; content: string }) {
|
||
const { t } = useTranslation()
|
||
const [showText, setShowText] = useState(false)
|
||
const [page, setPage] = useState(1)
|
||
const [zoom, setZoom] = useState(100)
|
||
const [reloadKey, setReloadKey] = useState(0)
|
||
const [document, setDocument] = useState<PDFDocumentProxy | null>(null)
|
||
const [pageCount, setPageCount] = useState(0)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||
|
||
useEffect(() => {
|
||
let disposed = false
|
||
let loadingTask: PDFDocumentLoadingTask | null = null
|
||
let loadedDocument: PDFDocumentProxy | null = null
|
||
|
||
setLoading(true)
|
||
setLoadError(null)
|
||
setDocument(null)
|
||
setPageCount(0)
|
||
setPage(1)
|
||
|
||
void (async () => {
|
||
try {
|
||
// PDF.js receives bytes over Tauri IPC because WebView-native PDF
|
||
// plugins and custom asset URLs are not portable across WebView2 and
|
||
// WebKitGTK. Base64 temporarily expands memory, so very large files
|
||
// must stay on the streaming system-reader path instead.
|
||
const fileSize = await getFileSize(filePath)
|
||
if (fileSize > MAX_INLINE_PDF_BYTES) throw new Error(t("preview.pdfTooLarge"))
|
||
const [{ getDocument, GlobalWorkerOptions }, workerModule, file] = await Promise.all([
|
||
// The legacy build includes the URL/Promise/AbortSignal polyfills
|
||
// required by older WebKitGTK runtimes still shipped by supported
|
||
// Linux distributions. Windows WebView2 also uses this same path.
|
||
import("pdfjs-dist/legacy/build/pdf.mjs"),
|
||
import("pdfjs-dist/legacy/build/pdf.worker.min.mjs?url"),
|
||
readFileAsBase64(filePath),
|
||
])
|
||
GlobalWorkerOptions.workerSrc = workerModule.default
|
||
loadingTask = getDocument({ data: decodeBase64(file.base64) })
|
||
loadedDocument = await loadingTask.promise
|
||
if (disposed) {
|
||
await loadedDocument.destroy()
|
||
return
|
||
}
|
||
setDocument(loadedDocument)
|
||
setPageCount(loadedDocument.numPages)
|
||
} catch (error) {
|
||
if (!disposed) setLoadError(error instanceof Error ? error.message : String(error))
|
||
} finally {
|
||
if (!disposed) setLoading(false)
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
disposed = true
|
||
if (loadingTask) void loadingTask.destroy()
|
||
else if (loadedDocument) void loadedDocument.destroy()
|
||
}
|
||
}, [filePath, reloadKey, t])
|
||
|
||
useEffect(() => {
|
||
if (!document || showText) return
|
||
const canvas = canvasRef.current
|
||
if (!canvas) return
|
||
let disposed = false
|
||
let renderTask: RenderTask | null = null
|
||
|
||
void (async () => {
|
||
try {
|
||
const pdfPage = await document.getPage(Math.min(page, document.numPages))
|
||
if (disposed) return
|
||
const viewport = pdfPage.getViewport({ scale: (zoom / 100) * 1.25 })
|
||
const pixelRatio = window.devicePixelRatio || 1
|
||
const context = canvas.getContext("2d")
|
||
if (!context) throw new Error("Canvas 2D rendering is unavailable")
|
||
canvas.width = Math.floor(viewport.width * pixelRatio)
|
||
canvas.height = Math.floor(viewport.height * pixelRatio)
|
||
canvas.style.width = `${Math.floor(viewport.width)}px`
|
||
canvas.style.height = `${Math.floor(viewport.height)}px`
|
||
renderTask = pdfPage.render({
|
||
canvas,
|
||
canvasContext: context,
|
||
viewport,
|
||
transform: pixelRatio === 1 ? undefined : [pixelRatio, 0, 0, pixelRatio, 0, 0],
|
||
})
|
||
await renderTask.promise
|
||
} catch (error) {
|
||
if (!disposed && !(error instanceof Error && error.name === "RenderingCancelledException")) {
|
||
setLoadError(error instanceof Error ? error.message : String(error))
|
||
}
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
disposed = true
|
||
renderTask?.cancel()
|
||
}
|
||
}, [document, page, showText, zoom])
|
||
|
||
return <div className="flex h-full min-h-0 flex-col p-4">
|
||
<div className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<span className="min-w-0 flex-1 truncate" title={filePath}>{filePath}</span>
|
||
<button type="button" className="rounded border px-2 py-1 hover:bg-muted" onClick={() => setShowText((value) => !value)}>{showText ? t("preview.pdfDocument") : t("preview.pdfText")}</button>
|
||
{!showText && <><button type="button" className="rounded p-1 hover:bg-muted" onClick={() => setZoom((value) => Math.max(50, value - 25))}><Minus className="h-3.5 w-3.5" /></button><span className="w-10 text-center">{zoom}%</span><button type="button" className="rounded p-1 hover:bg-muted" onClick={() => setZoom((value) => Math.min(300, value + 25))}><Plus className="h-3.5 w-3.5" /></button><label className="ml-1 flex items-center gap-1">{t("preview.pdfPage")}<input value={page} min={1} max={pageCount || undefined} type="number" onChange={(event) => setPage(clampPdfPage(Number(event.target.value) || 1, pageCount))} className="w-14 rounded border bg-background px-1 py-0.5" /></label><span>/ {pageCount || "–"}</span></>}
|
||
<button type="button" onClick={() => void openPath(filePath)} className="rounded p-1 hover:bg-muted" title={t("preview.openWithSystem")} aria-label={t("preview.openWithSystem")}><ExternalLink className="h-3.5 w-3.5" /></button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-white">
|
||
{showText ? <TextPreview filePath={filePath} content={content} label="PDF text" /> : loading ? (
|
||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">{t("preview.pdfLoading")}</div>
|
||
) : loadError ? (
|
||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||
<FileQuestion className="h-8 w-8" />
|
||
<p>{t("preview.pdfLoadError")}</p>
|
||
<p className="max-w-xl break-words text-xs opacity-70">{loadError}</p>
|
||
<button type="button" className="rounded border px-3 py-1.5 hover:bg-muted" onClick={() => setReloadKey((value) => value + 1)}>{t("preview.reload")}</button>
|
||
</div>
|
||
) : (
|
||
<div className="h-full overflow-auto bg-muted/30 p-4"><canvas ref={canvasRef} className="mx-auto bg-white shadow-sm" /></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
const MAX_INLINE_PDF_BYTES = 128 * 1024 * 1024
|
||
|
||
export function decodeBase64(value: string): Uint8Array {
|
||
const binary = atob(value)
|
||
const bytes = new Uint8Array(binary.length)
|
||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
||
return bytes
|
||
}
|
||
|
||
export function clampPdfPage(page: number, pageCount: number): number {
|
||
return Math.min(Math.max(1, Math.trunc(page)), Math.max(1, pageCount))
|
||
}
|
||
|
||
export function parseDelimitedContent(content: string, delimiter: string, maxRows = 500): string[][] {
|
||
const rows: string[][] = []
|
||
let cells: string[] = []
|
||
let current = ""
|
||
let quoted = false
|
||
const normalized = content.replace(/\r\n/g, "\n")
|
||
for (let index = 0; index < normalized.length && rows.length < maxRows; index += 1) {
|
||
const char = normalized[index]
|
||
if (char === '"') {
|
||
if (quoted && normalized[index + 1] === '"') { current += '"'; index += 1 } else quoted = !quoted
|
||
} else if (char === delimiter && !quoted) { cells.push(current); current = "" } else current += char
|
||
if (char === "\n" && !quoted) {
|
||
current = current.slice(0, -1)
|
||
cells.push(current); rows.push(cells); cells = []; current = ""
|
||
}
|
||
}
|
||
if ((current || cells.length > 0) && rows.length < maxRows) { cells.push(current); rows.push(cells) }
|
||
return rows
|
||
}
|
||
|
||
function DelimitedTablePreview({ filePath, content, delimiter }: { filePath: string; content: string; delimiter: string }) {
|
||
const rows = useMemo(() => parseDelimitedContent(content, delimiter), [content, delimiter])
|
||
return <div className="h-full overflow-auto p-4"><div className="mb-2 text-xs text-muted-foreground">{filePath}</div><table className="min-w-full border-collapse text-xs"><thead className="sticky top-0 bg-muted">{rows[0] && <tr>{rows[0].map((cell, index) => <th key={index} className="border px-2 py-1 text-left">{cell}</th>)}</tr>}</thead><tbody>{rows.slice(1).map((row, rowIndex) => <tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex} className="max-w-80 border px-2 py-1 align-top">{cell}</td>)}</tr>)}</tbody></table></div>
|
||
}
|
||
|
||
function HtmlPreview({
|
||
filePath,
|
||
fileName,
|
||
content,
|
||
}: {
|
||
filePath: string
|
||
fileName: string
|
||
content: string
|
||
}) {
|
||
const { t } = useTranslation()
|
||
const src = convertFileSrc(filePath)
|
||
const [showSource, setShowSource] = useState(false)
|
||
const [reloadKey, setReloadKey] = useState(0)
|
||
return (
|
||
<div className="flex h-full flex-col p-4" data-preview-kind="html">
|
||
<div className="mb-3 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<span className="min-w-0 flex-1 truncate" title={filePath}>{filePath}</span>
|
||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase">HTML</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowSource((current) => !current)}
|
||
className="rounded p-1 hover:bg-accent hover:text-foreground"
|
||
title={showSource ? t("preview.showRendered") : t("preview.showSource")}
|
||
aria-label={showSource ? t("preview.showRendered") : t("preview.showSource")}
|
||
>
|
||
<Code2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
{!showSource && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setReloadKey((current) => current + 1)}
|
||
className="rounded p-1 hover:bg-accent hover:text-foreground"
|
||
title={t("preview.reload")}
|
||
aria-label={t("preview.reload")}
|
||
>
|
||
<RefreshCw className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => void openPath(filePath)}
|
||
className="rounded p-1 hover:bg-accent hover:text-foreground"
|
||
title={t("preview.openWithSystem")}
|
||
aria-label={t("preview.openWithSystem")}
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-border bg-background">
|
||
{showSource ? (
|
||
<pre className="h-full overflow-auto whitespace-pre-wrap break-words p-4 font-mono text-xs">
|
||
{content}
|
||
</pre>
|
||
) : (
|
||
<iframe
|
||
key={reloadKey}
|
||
title={fileName}
|
||
src={src}
|
||
className="h-full w-full bg-white"
|
||
// Generated HTML is untrusted Agent output. Scripts are useful for
|
||
// interactive reports, but same-origin access stays disabled so the
|
||
// document cannot reach the parent DOM or authenticated app APIs.
|
||
sandbox="allow-scripts"
|
||
referrerPolicy="no-referrer"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StandaloneMermaidPreview({ filePath, content }: { filePath: string; content: string }) {
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col overflow-auto p-6" data-preview-kind="mermaid">
|
||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span className="min-w-0 flex-1 truncate" title={filePath}>{filePath}</span>
|
||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase">Mermaid</span>
|
||
</div>
|
||
<MermaidDiagram code={content} />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function isAgentWorkspacePath(filePath: string): boolean {
|
||
return normalizePath(filePath).split("/").includes("agent-workspace")
|
||
}
|
||
|
||
function extractedTextLabel(filePath: string): string {
|
||
switch (getFileExtension(filePath)) {
|
||
case "doc":
|
||
return "Word DOC (extracted text)"
|
||
case "docx":
|
||
return "Word DOCX (extracted text)"
|
||
case "pptx":
|
||
return "PowerPoint (extracted text)"
|
||
case "xls":
|
||
case "xlsx":
|
||
return "Spreadsheet (extracted text)"
|
||
case "odt":
|
||
case "ods":
|
||
case "odp":
|
||
return "OpenDocument (extracted text)"
|
||
default:
|
||
return "Extracted text"
|
||
}
|
||
}
|
||
|
||
function ImagePreview({ filePath, fileName }: { filePath: string; fileName: string }) {
|
||
const src = convertFileSrc(filePath)
|
||
const [expanded, setExpanded] = useState(false)
|
||
const [zoom, setZoom] = useState(1)
|
||
return (
|
||
<div className="flex h-full flex-col p-6">
|
||
<div className="mb-4 flex items-center gap-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1 truncate">{filePath}</span><button type="button" onClick={() => setExpanded(true)} className="rounded p-1 hover:bg-muted"><Maximize2 className="h-4 w-4" /></button></div>
|
||
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-muted/30">
|
||
<img
|
||
src={src}
|
||
alt={fileName}
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
</div>
|
||
{expanded && <div className="fixed inset-0 z-[100] flex flex-col bg-background/95 p-4 backdrop-blur-sm"><div className="flex justify-end gap-1"><button type="button" className="rounded p-2 hover:bg-muted" onClick={() => setZoom((value) => Math.max(.25, value - .25))}><Minus className="h-4 w-4" /></button><button type="button" className="rounded p-2 hover:bg-muted" onClick={() => setZoom((value) => Math.min(5, value + .25))}><Plus className="h-4 w-4" /></button><button type="button" className="rounded p-2 hover:bg-muted" onClick={() => setExpanded(false)}><X className="h-4 w-4" /></button></div><div className="min-h-0 flex-1 overflow-auto text-center"><img src={src} alt={fileName} className="mx-auto max-w-none object-contain" style={{ width: `${zoom * 100}%` }} /></div></div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function VideoPreview({ filePath, fileName }: { filePath: string; fileName: string }) {
|
||
const src = convertFileSrc(filePath)
|
||
return (
|
||
<div className="flex h-full flex-col p-6">
|
||
<div className="mb-4 text-xs text-muted-foreground">{filePath}</div>
|
||
<div className="flex flex-1 items-center justify-center overflow-auto rounded-lg bg-black">
|
||
<video
|
||
src={src}
|
||
controls
|
||
className="max-h-full max-w-full"
|
||
>
|
||
<track kind="captions" label={fileName} />
|
||
</video>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AudioPreview({ filePath, fileName }: { filePath: string; fileName: string }) {
|
||
const src = convertFileSrc(filePath)
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
|
||
<div className="text-xs text-muted-foreground">{filePath}</div>
|
||
<Music className="h-16 w-16 text-muted-foreground/50" />
|
||
<p className="text-sm font-medium">{fileName}</p>
|
||
<audio src={src} controls className="w-full max-w-md">
|
||
<track kind="captions" label={fileName} />
|
||
</audio>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function CodePreview({ filePath, content }: { filePath: string; content: string }) {
|
||
const lang = getCodeLanguage(filePath)
|
||
return (
|
||
<div className="h-full overflow-auto p-6">
|
||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span>{filePath}</span>
|
||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase">{lang}</span>
|
||
</div>
|
||
<pre className="whitespace-pre-wrap rounded-lg bg-muted/30 p-4 font-mono text-sm">
|
||
{content}
|
||
</pre>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TextPreview({ filePath, content, label }: { filePath: string; content: string; label: string }) {
|
||
const projectPath = useWikiStore((s) => s.project?.path ?? null)
|
||
const pendingScrollImageSrc = useWikiStore((s) => s.pendingScrollImageSrc)
|
||
const setPendingScrollImageSrc = useWikiStore((s) => s.setPendingScrollImageSrc)
|
||
const scrollRootRef = useRef<HTMLDivElement | null>(null)
|
||
|
||
const { frontmatter, body } = useMemo(() => parseFrontmatter(content), [content])
|
||
// Rewrite Obsidian image embeds (`![[…]]`) into standard markdown
|
||
// so raw-source previews (e.g. skill-exported docs) actually show
|
||
// their images instead of dumping the embed syntax as text.
|
||
const renderBody = useMemo(() => transformImageEmbeds(body), [body])
|
||
// Directory of this file (project-absolute) so relative image
|
||
// references (`../assets/x.png`) resolve against the file's own
|
||
// location, Obsidian-style.
|
||
const currentFileDir = useMemo(() => {
|
||
const norm = normalizePath(filePath)
|
||
const dir = norm.slice(0, norm.lastIndexOf("/"))
|
||
return dir || null
|
||
}, [filePath])
|
||
const renderLanguage = useMemo(() => detectLanguage(body), [body])
|
||
const direction = getTextDirection(renderLanguage)
|
||
const htmlLang = getHtmlLang(renderLanguage)
|
||
|
||
// Consume `pendingScrollImageSrc` once the file has rendered.
|
||
// We re-scan the DOM whenever:
|
||
// - file content changes (different page just loaded), OR
|
||
// - the pending target changes (user clicked a different image)
|
||
// Image loading is async, so we also subscribe to `load` events
|
||
// and rescroll once the actual layout settles — the first
|
||
// `scrollIntoView` lands on a 0-height placeholder otherwise.
|
||
useEffect(() => {
|
||
if (!pendingScrollImageSrc) return
|
||
const root = scrollRootRef.current
|
||
if (!root) return
|
||
// Match by `data-mdsrc` (literal markdown URL) — the post-
|
||
// resolver `src` is a tauri:// URL we don't want to bake into
|
||
// the search-result data.
|
||
// Inline-escape `"` and `\` for the attribute-VALUE position
|
||
// of a CSS selector (CSS.escape is for IDENTIFIER context and
|
||
// would over-escape here). Image URLs can in principle contain
|
||
// either, so doing this is correctness, not paranoia.
|
||
const escapedSrc = pendingScrollImageSrc
|
||
.replace(/\\/g, "\\\\")
|
||
.replace(/"/g, '\\"')
|
||
const target = root.querySelector<HTMLImageElement>(
|
||
`img[data-mdsrc="${escapedSrc}"]`,
|
||
)
|
||
if (!target) {
|
||
// Page may not actually contain this image — clear the
|
||
// pending so a future page-open doesn't get an unexpected
|
||
// scroll. Fail silently: the user navigated, we just don't
|
||
// know where to send them.
|
||
setPendingScrollImageSrc(null)
|
||
return
|
||
}
|
||
// Initial scroll. The image may not have loaded its bytes yet
|
||
// (lazy loading + remote PNG decode) so this lands on a
|
||
// 0-height box. After load, recompute.
|
||
target.scrollIntoView({ behavior: "auto", block: "center" })
|
||
if (!target.complete) {
|
||
const onLoad = () => {
|
||
target.scrollIntoView({ behavior: "smooth", block: "center" })
|
||
target.removeEventListener("load", onLoad)
|
||
}
|
||
target.addEventListener("load", onLoad)
|
||
}
|
||
// Briefly highlight the target so the user sees where they
|
||
// landed — the page might be long and the image might be in
|
||
// a section visually similar to its neighbors.
|
||
target.classList.add("ring-2", "ring-primary", "ring-offset-2")
|
||
const tHighlight = setTimeout(() => {
|
||
target.classList.remove("ring-2", "ring-primary", "ring-offset-2")
|
||
}, 1800)
|
||
setPendingScrollImageSrc(null)
|
||
return () => clearTimeout(tHighlight)
|
||
}, [pendingScrollImageSrc, content, setPendingScrollImageSrc])
|
||
|
||
return (
|
||
<div ref={scrollRootRef} className="h-full overflow-auto p-6">
|
||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span>{filePath}</span>
|
||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase">{label}</span>
|
||
</div>
|
||
{frontmatter && <FrontmatterPanel data={frontmatter} />}
|
||
<div
|
||
className="prose prose-sm max-w-none dark:prose-invert"
|
||
dir={direction}
|
||
lang={htmlLang}
|
||
style={{ textAlign: "start" }}
|
||
>
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm, remarkMath]}
|
||
rehypePlugins={[rehypeKatex]}
|
||
components={{
|
||
// Resolve `` references generated by the
|
||
// ingest image-extraction step. Without this, the
|
||
// browser tries to load `media/...` relative to the
|
||
// webview origin and silently 404s.
|
||
//
|
||
// `data-mdsrc` preserves the ORIGINAL markdown URL
|
||
// (pre-resolver) so the search-result jump-to-image
|
||
// path can find the rendered <img> by its source-of-
|
||
// truth identifier rather than the resolved tauri://
|
||
// URL (which differs per platform).
|
||
img: ({ src, alt, ...props }) => (
|
||
<img
|
||
src={typeof src === "string" ? resolveMarkdownImageSrc(src, projectPath, currentFileDir) : undefined}
|
||
data-mdsrc={typeof src === "string" ? src : undefined}
|
||
alt={alt ?? ""}
|
||
className="max-w-full rounded border border-border/40 transition-all"
|
||
loading="lazy"
|
||
{...props}
|
||
/>
|
||
),
|
||
table: ({ children, ...props }) => (
|
||
<div className="my-2 overflow-x-auto rounded border border-border">
|
||
<table className="w-full border-collapse text-xs" {...props}>{children}</table>
|
||
</div>
|
||
),
|
||
thead: ({ children, ...props }) => (
|
||
<thead className="bg-muted" {...props}>{children}</thead>
|
||
),
|
||
th: ({ children, ...props }) => (
|
||
<th className="border border-border/80 px-3 py-1.5 text-start font-semibold bg-muted" {...props}>{children}</th>
|
||
),
|
||
td: ({ children, ...props }) => (
|
||
<td className="border border-border/60 px-3 py-1.5" {...props}>{children}</td>
|
||
),
|
||
pre: ({ children, ...props }) => {
|
||
const mermaid = unwrapMermaidPre(children)
|
||
if (mermaid) return <>{mermaid}</>
|
||
return <pre dir="ltr" style={{ textAlign: "left" }} {...props}>{children}</pre>
|
||
},
|
||
code: ({ className, children, ...props }) => {
|
||
const lang = className?.replace("language-", "")
|
||
const codeText = String(children).replace(/\n$/, "")
|
||
if (lang === "mermaid") return <MermaidDiagram code={codeText} />
|
||
return <code dir="ltr" className={className} {...props}>{children}</code>
|
||
},
|
||
}}
|
||
>
|
||
{renderBody}
|
||
</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BinaryPlaceholder({
|
||
filePath,
|
||
fileName,
|
||
category,
|
||
}: {
|
||
filePath: string
|
||
fileName: string
|
||
category: FileCategory
|
||
}) {
|
||
const { t } = useTranslation()
|
||
const [text, setText] = useState<string | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
const iconMap: Record<string, typeof FileText> = {
|
||
document: FileSpreadsheet,
|
||
unknown: FileQuestion,
|
||
image: ImageIcon,
|
||
video: Film,
|
||
}
|
||
const Icon = iconMap[category] ?? FileQuestion
|
||
|
||
if (text !== null) {
|
||
return <CodePreview filePath={filePath} content={text} />
|
||
}
|
||
|
||
const viewAsText = async () => {
|
||
setLoading(true)
|
||
setLoadError(null)
|
||
try {
|
||
// read_file is size-bounded on the Rust side. The explicit user action is
|
||
// the opt-in for long-tail text formats; decoding failures remain visible
|
||
// here instead of being mistaken for an empty file.
|
||
const { readFile } = await import("@/commands/fs")
|
||
setText(await readFile(filePath))
|
||
} catch (error) {
|
||
setLoadError(error instanceof Error ? error.message : String(error))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6 text-center">
|
||
<Icon className="h-16 w-16 text-muted-foreground/30" />
|
||
<div>
|
||
<p className="text-sm font-medium">{fileName}</p>
|
||
<p className="mt-1 text-xs text-muted-foreground">{filePath}</p>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
{t("preview.notAvailable")}
|
||
</p>
|
||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => void viewAsText()}
|
||
disabled={loading}
|
||
className="rounded-md border border-border bg-background px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||
>
|
||
{loading ? t("preview.loadingText") : t("preview.viewAsText")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void openPath(filePath)}
|
||
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-xs hover:bg-accent"
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5" />
|
||
{t("preview.openWithSystem")}
|
||
</button>
|
||
</div>
|
||
{loadError && <p className="max-w-lg text-xs text-destructive">{loadError}</p>}
|
||
</div>
|
||
)
|
||
}
|