mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
feat(editor): implement viewer mode for large documents, switching between 'plate' and 'monaco' editors based on document size
This commit is contained in:
parent
c2b8b3ac5e
commit
cf8a2d0759
4 changed files with 100 additions and 71 deletions
|
|
@ -51,10 +51,12 @@ interface EditorContent {
|
|||
content_size_bytes?: number;
|
||||
chunk_count?: number;
|
||||
truncated?: boolean;
|
||||
viewer_mode?: ViewerMode;
|
||||
}
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
type EditorRenderMode = "rich_markdown" | "source_code";
|
||||
type ViewerMode = "plate" | "monaco";
|
||||
|
||||
type AgentFilesystemMount = {
|
||||
mount: string;
|
||||
|
|
@ -168,6 +170,9 @@ export function EditorPanelContent({
|
|||
);
|
||||
|
||||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
const viewerMode: ViewerMode = isMemoryMode
|
||||
? "plate"
|
||||
: (editorDoc?.viewer_mode ?? (isLargeDocument ? "monaco" : "plate"));
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -432,12 +437,10 @@ export function EditorPanelContent({
|
|||
? (isMemoryMode ||
|
||||
editorRenderMode === "source_code" ||
|
||||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
||||
!isLargeDocument
|
||||
viewerMode === "plate"
|
||||
: false;
|
||||
// Render through PlateEditor for editable doc types (FILE/NOTE).
|
||||
// Everything else (large docs, non-editable types) falls back to the
|
||||
// lightweight `MarkdownViewer` — Plate is heavy on multi-MB docs and
|
||||
// non-editable types don't benefit from its editing UX.
|
||||
// Render through PlateEditor only when the backend says the rich editor is safe.
|
||||
// Monaco mode is a raw markdown safety path for large documents.
|
||||
const renderInPlateEditor = isEditableType;
|
||||
const hasUnsavedChanges = editedMarkdown !== null;
|
||||
const showDesktopHeader = !!onClose;
|
||||
|
|
@ -492,14 +495,14 @@ export function EditorPanelContent({
|
|||
}
|
||||
}, [documentId, editorDoc?.title, searchSpaceId]);
|
||||
|
||||
const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && (
|
||||
<Alert className="mb-4">
|
||||
const largeDocAlert = viewerMode === "monaco" && !isLocalFileMode && editorDoc && (
|
||||
<Alert className="m-4 shrink-0">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{Math.round((editorDoc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
|
||||
{editorDoc.chunk_count ?? 0} chunks). Showing a preview below.
|
||||
{editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -753,12 +756,19 @@ export function EditorPanelContent({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isLargeDocument && !isLocalFileMode ? (
|
||||
// Large doc — fast Streamdown preview + download CTA.
|
||||
// Plate is heavy on multi-MB docs.
|
||||
<div className="h-full overflow-y-auto px-5 py-4">
|
||||
) : viewerMode === "monaco" && !isLocalFileMode ? (
|
||||
// Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{largeDocAlert}
|
||||
<MarkdownViewer content={editorDoc.source_markdown} enableCitations />
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<SourceCodeEditor
|
||||
path={`${editorDoc.title || "document"}.md`}
|
||||
language="markdown"
|
||||
value={editorDoc.source_markdown}
|
||||
readOnly
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : renderInPlateEditor ? (
|
||||
// Editable doc (FILE/NOTE) — Plate editing UX.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PlateEditor } from "@/components/editor/plate-editor";
|
||||
import { SourceCodeEditor } from "@/components/editor/source-code-editor";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -22,6 +23,7 @@ interface DocumentContent {
|
|||
content_size_bytes?: number;
|
||||
chunk_count?: number;
|
||||
truncated?: boolean;
|
||||
viewer_mode?: ViewerMode;
|
||||
}
|
||||
|
||||
function DocumentSkeleton() {
|
||||
|
|
@ -51,6 +53,7 @@ interface DocumentTabContentProps {
|
|||
}
|
||||
|
||||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
type ViewerMode = "plate" | "monaco";
|
||||
|
||||
export function DocumentTabContent({ documentId, searchSpaceId, title }: DocumentTabContentProps) {
|
||||
const [doc, setDoc] = useState<DocumentContent | null>(null);
|
||||
|
|
@ -66,6 +69,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
const router = useRouter();
|
||||
|
||||
const isLargeDocument = (doc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
const viewerMode: ViewerMode = doc?.viewer_mode ?? (isLargeDocument ? "monaco" : "plate");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
|
@ -204,9 +208,9 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
);
|
||||
}
|
||||
|
||||
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "") && !isLargeDocument;
|
||||
const isEditable = viewerMode === "plate" && EDITABLE_DOCUMENT_TYPES.has(doc.document_type ?? "");
|
||||
|
||||
if (isEditing && !isLargeDocument) {
|
||||
if (isEditing && viewerMode === "plate") {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0">
|
||||
|
|
@ -265,64 +269,74 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||
{isLargeDocument ? (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
|
||||
{doc.chunk_count ?? 0} chunks). Showing a preview below.
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{viewerMode === "monaco" ? (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Alert className="m-4 shrink-0">
|
||||
<FileText className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
This document is too large for the editor (
|
||||
{Math.round((doc.content_size_bytes ?? 0) / 1024 / 1024)}MB,{" "}
|
||||
{doc.chunk_count ?? 0} chunks). Showing raw markdown below.
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
const match = disposition?.match(/filename="(.+)"/);
|
||||
a.download = match?.[1] ?? `${doc.title || "document"}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Download started");
|
||||
} catch {
|
||||
toast.error("Failed to download document");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
if (!response.ok) throw new Error("Download failed");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const disposition = response.headers.get("content-disposition");
|
||||
const match = disposition?.match(/filename="(.+)"/);
|
||||
a.download = match?.[1] ?? `${doc.title || "document"}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Download started");
|
||||
} catch {
|
||||
toast.error("Failed to download document");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<SourceCodeEditor
|
||||
path={`${doc.title || "document"}.md`}
|
||||
language="markdown"
|
||||
value={doc.source_markdown}
|
||||
readOnly
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="max-w-4xl mx-auto px-6 py-6">
|
||||
<MarkdownViewer content={doc.source_markdown} enableCitations />
|
||||
</>
|
||||
) : (
|
||||
<MarkdownViewer content={doc.source_markdown} enableCitations />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export type EditorContent = {
|
|||
content_size_bytes: number;
|
||||
chunk_count: number;
|
||||
truncated: boolean;
|
||||
viewer_mode?: "plate" | "monaco";
|
||||
};
|
||||
|
||||
// Same endpoint the UI hits when a user opens a document in the dashboard.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue