mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
refactor: citation viewer
This commit is contained in:
parent
ca9bbee06d
commit
f23be16b35
14 changed files with 362 additions and 755 deletions
|
|
@ -1,13 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pendingChunkHighlightAtom } from "@/atoms/document-viewer/pending-chunk-highlight.atom";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Citation } from "@/components/tool-ui/citation";
|
||||
|
|
@ -29,11 +27,11 @@ const POPOVER_HOVER_CLOSE_DELAY_MS = 150;
|
|||
* Surfsense documentation chunks (`isDocsChunk`). Negative chunk IDs render as
|
||||
* a static "doc" pill (anonymous/synthetic uploads).
|
||||
*
|
||||
* Numeric KB chunks: clicking resolves the parent document via
|
||||
* `getDocumentByChunk`, opens the document in the right side panel (alongside
|
||||
* the chat — does not replace it), and stages the cited chunk text in
|
||||
* `pendingChunkHighlightAtom` so `EditorPanelContent` can scroll to and softly
|
||||
* highlight it inside the rendered markdown.
|
||||
* Numeric KB chunks: clicking opens the citation panel in the right
|
||||
* sidebar (alongside the chat — does not replace it). The panel shows
|
||||
* the cited chunk surrounded by adjacent chunks (via the API's
|
||||
* `chunk_window`), with the cited one highlighted and an option to
|
||||
* expand the window or jump into the full document via the editor panel.
|
||||
*
|
||||
* Surfsense docs chunks: rendered as a hover-controlled shadcn Popover that
|
||||
* lazily fetches and previews the cited chunk inline, since those docs aren't
|
||||
|
|
@ -65,71 +63,17 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, isDocsChunk =
|
|||
};
|
||||
|
||||
const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const setPendingHighlight = useSetAtom(pendingChunkHighlightAtom);
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (resolving) return;
|
||||
setResolving(true);
|
||||
console.log("[citation:click] start", { chunkId });
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
// Local key with explicit window. The shared `cacheKeys.documents.byChunk`
|
||||
// is window-agnostic (latent footgun); namespace the call to avoid
|
||||
// reusing a different-window cached result.
|
||||
queryKey: ["documents", "by-chunk", chunkId, "w0"] as const,
|
||||
queryFn: () =>
|
||||
documentsApiService.getDocumentByChunk({ chunk_id: chunkId, chunk_window: 0 }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const cited = data.chunks.find((c) => c.id === chunkId) ?? data.chunks[0];
|
||||
console.log("[citation:click] fetched doc-by-chunk", {
|
||||
docId: data.id,
|
||||
docTitle: data.title,
|
||||
chunksReturned: data.chunks.length,
|
||||
citedChunkId: cited?.id,
|
||||
citedChunkContentLen: cited?.content?.length ?? 0,
|
||||
citedChunkPreview:
|
||||
cited?.content && cited.content.length > 120
|
||||
? `${cited.content.slice(0, 120)}…(+${cited.content.length - 120})`
|
||||
: (cited?.content ?? ""),
|
||||
});
|
||||
// Stage the highlight BEFORE opening the panel so `EditorPanelContent`
|
||||
// already sees the pending intent on its very first render — avoids a
|
||||
// "fetch → render → no-pending → next-tick render with pending" race.
|
||||
setPendingHighlight({
|
||||
documentId: data.id,
|
||||
chunkId,
|
||||
chunkText: cited?.content ?? "",
|
||||
});
|
||||
openEditorPanel({
|
||||
documentId: data.id,
|
||||
searchSpaceId: data.search_space_id,
|
||||
title: data.title,
|
||||
});
|
||||
console.log("[citation:click] staged highlight + opened editor panel", {
|
||||
documentId: data.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[citation:click] failed", err);
|
||||
toast.error(err instanceof Error ? err.message : "Couldn't open cited document");
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [chunkId, openEditorPanel, queryClient, resolving, setPendingHighlight]);
|
||||
const openCitationPanel = useSetAtom(openCitationPanelAtom);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
disabled={resolving}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none disabled:cursor-progress disabled:opacity-70"
|
||||
onClick={() => openCitationPanel({ chunkId })}
|
||||
className="ml-0.5 inline-flex h-5 min-w-5 cursor-pointer items-center justify-center rounded-md bg-muted/60 px-1.5 text-[11px] font-medium text-muted-foreground align-baseline shadow-sm transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none"
|
||||
title={`View source chunk #${chunkId}`}
|
||||
aria-label={`Jump to cited chunk ${chunkId}`}
|
||||
aria-label={`View cited chunk ${chunkId}`}
|
||||
>
|
||||
{resolving ? <Spinner size="xs" /> : chunkId}
|
||||
{chunkId}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
230
surfsense_web/components/citation-panel/citation-panel.tsx
Normal file
230
surfsense_web/components/citation-panel/citation-panel.tsx
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { ChevronDown, ChevronUp, ExternalLink, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
|
||||
const DEFAULT_CHUNK_WINDOW = 5;
|
||||
const EXPANDED_CHUNK_WINDOW = 50;
|
||||
|
||||
interface CitationPanelContentProps {
|
||||
chunkId: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-panel citation viewer. Shows the cited chunk surrounded by
|
||||
* adjacent chunks (±N chunks via the API's `chunk_window` parameter),
|
||||
* with the cited one visually highlighted and auto-scrolled into view.
|
||||
* The window can be expanded to a wider range, or the user can jump to
|
||||
* the full document via the editor panel.
|
||||
*/
|
||||
export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, onClose }) => {
|
||||
const openEditorPanel = useSetAtom(openEditorPanelAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(false);
|
||||
}, []);
|
||||
|
||||
const chunkWindow = expanded ? EXPANDED_CHUNK_WINDOW : DEFAULT_CHUNK_WINDOW;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["citation-panel", chunkId, chunkWindow] as const,
|
||||
queryFn: () =>
|
||||
documentsApiService.getDocumentByChunk({
|
||||
chunk_id: chunkId,
|
||||
chunk_window: chunkWindow,
|
||||
}),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const cited = useMemo(() => data?.chunks.find((c) => c.id === chunkId) ?? null, [data, chunkId]);
|
||||
|
||||
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
|
||||
const startIndex = data?.chunk_start_index ?? 0;
|
||||
const citedIndexInWindow = data
|
||||
? Math.max(
|
||||
0,
|
||||
data.chunks.findIndex((c) => c.id === chunkId)
|
||||
)
|
||||
: 0;
|
||||
const shownAbove = citedIndexInWindow;
|
||||
const shownBelow = data ? Math.max(0, data.chunks.length - 1 - citedIndexInWindow) : 0;
|
||||
const hasMoreAbove = startIndex > 0;
|
||||
const hasMoreBelow = data ? startIndex + data.chunks.length < totalChunks : false;
|
||||
|
||||
// Scroll the cited chunk into view inside the panel's scroll container
|
||||
// (not the page). We anchor the scroll to the panel's scroll element
|
||||
// so opening the citation doesn't yank the chat scroll on the left.
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const citedRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!cited) return;
|
||||
const id = requestAnimationFrame(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
const target = citedRef.current;
|
||||
if (!container || !target) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const offset = targetRect.top - containerRect.top + container.scrollTop;
|
||||
container.scrollTo({
|
||||
top: Math.max(0, offset - 16),
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [cited]);
|
||||
|
||||
const handleOpenFullDocument = () => {
|
||||
if (!data) return;
|
||||
openEditorPanel({
|
||||
documentId: data.id,
|
||||
searchSpaceId: data.search_space_id,
|
||||
title: data.title,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 border-b">
|
||||
<div className="flex h-14 items-center justify-between px-4">
|
||||
<h2 className="text-lg font-medium text-muted-foreground select-none">Citation</h2>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close citation panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 text-[11px] text-muted-foreground">
|
||||
<span>Chunk #{chunkId}</span>
|
||||
{totalChunks > 0 && <span>· {totalChunks} chunks</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 py-8 text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">Loading citation…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="py-8 text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load citation"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && data && (
|
||||
<>
|
||||
{hasMoreAbove && (
|
||||
<p className="mb-3 text-center text-[11px] text-muted-foreground">
|
||||
… {startIndex} earlier chunk{startIndex === 1 ? "" : "s"} not shown
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{data.chunks.map((chunk) => {
|
||||
const isCited = chunk.id === chunkId;
|
||||
return (
|
||||
<div
|
||||
key={chunk.id}
|
||||
ref={isCited ? citedRef : null}
|
||||
data-cited={isCited || undefined}
|
||||
className={
|
||||
isCited
|
||||
? "rounded-md border-2 border-primary bg-primary/5 px-4 py-3 shadow-sm"
|
||||
: "rounded-md border border-border/40 bg-muted/20 px-4 py-3 opacity-70 transition-opacity hover:opacity-100"
|
||||
}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span
|
||||
className={
|
||||
isCited
|
||||
? "text-[11px] font-semibold text-primary"
|
||||
: "text-[11px] font-medium text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{isCited ? "Cited chunk" : `Chunk #${chunk.id}`}
|
||||
</span>
|
||||
{isCited && (
|
||||
<span className="text-[11px] text-muted-foreground">#{chunk.id}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<MarkdownViewer content={chunk.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreBelow && (
|
||||
<p className="mt-3 text-center text-[11px] text-muted-foreground">
|
||||
… {totalChunks - (startIndex + data.chunks.length)} later chunk
|
||||
{totalChunks - (startIndex + data.chunks.length) === 1 ? "" : "s"} not shown
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && !error && data && (
|
||||
<div className="shrink-0 flex flex-wrap items-center justify-between gap-2 border-t px-4 py-3">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
Showing {shownAbove} above · cited · {shownBelow} below
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(hasMoreAbove || hasMoreBelow) && !expanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<ChevronDown className="mr-1 size-3.5" />
|
||||
More context
|
||||
</Button>
|
||||
)}
|
||||
{expanded && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<ChevronUp className="mr-1 size-3.5" />
|
||||
Less
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleOpenFullDocument}
|
||||
>
|
||||
<ExternalLink className="mr-1 size-3.5" />
|
||||
Open full document
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { FindReplacePlugin } from "@platejs/find-replace";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
Check,
|
||||
|
|
@ -15,21 +14,17 @@ import {
|
|||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pendingChunkHighlightAtom } from "@/atoms/document-viewer/pending-chunk-highlight.atom";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { VersionHistoryButton } from "@/components/documents/version-history";
|
||||
import type { PlateEditorInstance } 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";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { CITATION_HIGHLIGHT_CLASS } from "@/components/ui/search-highlight-node";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
import { buildCitationSearchCandidates } from "@/lib/citation-search";
|
||||
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
|
||||
|
||||
const PlateEditor = dynamic(
|
||||
|
|
@ -37,10 +32,7 @@ const PlateEditor = dynamic(
|
|||
{ ssr: false, loading: () => <EditorPanelSkeleton /> }
|
||||
);
|
||||
|
||||
type CitationHighlightStatus = "exact" | "miss";
|
||||
|
||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||
const CITATION_MAX_LENGTH = 16 * 1024 * 1024; // 16MB on-demand cap for citation jumps
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
|
|
@ -145,60 +137,6 @@ export function EditorPanelContent({
|
|||
const isLocalFileMode = kind === "local_file";
|
||||
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
||||
|
||||
// --- Citation-jump highlight wiring ----------------------------------
|
||||
// `EditorPanelContent` is the consumer of `pendingChunkHighlightAtom`: when
|
||||
// a citation badge is clicked, the badge stages `{documentId, chunkId,
|
||||
// chunkText}` and opens this panel. We drive Plate's `FindReplacePlugin`
|
||||
// (registered in every preset) to highlight the cited text natively via
|
||||
// Slate decorations — no DOM walking, no Range gymnastics. The state
|
||||
// machine below escalates the document fetch from 2MB → 16MB once if no
|
||||
// candidate snippet matched in the preview, and surfaces miss outcomes
|
||||
// via an inline alert.
|
||||
const pending = useAtomValue(pendingChunkHighlightAtom);
|
||||
const setPendingHighlight = useSetAtom(pendingChunkHighlightAtom);
|
||||
const [fetchKey, setFetchKey] = useState(0);
|
||||
const [maxLengthOverride, setMaxLengthOverride] = useState<number | null>(null);
|
||||
const [highlightResult, setHighlightResult] = useState<CitationHighlightStatus | null>(null);
|
||||
const editorRef = useRef<PlateEditorInstance | null>(null);
|
||||
const escalatedForRef = useRef<number | null>(null);
|
||||
const lastAppliedChunkIdRef = useRef<number | null>(null);
|
||||
// Tracks whether a citation highlight is currently decorated in the
|
||||
// editor. We use a ref (not state) because the click-to-dismiss handler
|
||||
// runs in a stable callback that would otherwise close over stale state.
|
||||
const isHighlightActiveRef = useRef(false);
|
||||
// Once a citation jump targets this doc we have to keep `PlateEditor`
|
||||
// mounted for the *rest of the doc session* — even after the highlight
|
||||
// effect clears `pendingChunkHighlightAtom` (which it does as soon as
|
||||
// the decoration is applied, so a follow-up citation on the same chunk
|
||||
// can re-trigger). Without this latch, non-editable docs would re-render
|
||||
// back into `MarkdownViewer` the instant `pending` is released, tearing
|
||||
// down the Plate decorations and dropping the highlight after a frame.
|
||||
const [stickyPlateMode, setStickyPlateMode] = useState(false);
|
||||
|
||||
const clearCitationSearch = useCallback(() => {
|
||||
isHighlightActiveRef.current = false;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
try {
|
||||
editor.setOption(FindReplacePlugin, "search", "");
|
||||
editor.api.redecorate();
|
||||
} catch (err) {
|
||||
console.warn("[EditorPanelContent] clearCitationSearch failed:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Dismiss the highlight when the user interacts with the editor surface.
|
||||
// `onPointerDown` fires before focus / selection changes so the click
|
||||
// itself feels responsive — the highlight clears in the same event tick
|
||||
// that places the cursor. No-op when nothing is highlighted, so we don't
|
||||
// thrash `redecorate` on every click in normal editing.
|
||||
const handleEditorPointerDown = useCallback(() => {
|
||||
if (!isHighlightActiveRef.current) return;
|
||||
clearCitationSearch();
|
||||
setHighlightResult(null);
|
||||
}, [clearCitationSearch]);
|
||||
|
||||
const isCitationTarget = !!pending && !isLocalFileMode && pending.documentId === documentId;
|
||||
const resolveLocalVirtualPath = useCallback(
|
||||
async (candidatePath: string): Promise<string> => {
|
||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||
|
|
@ -218,8 +156,6 @@ export function EditorPanelContent({
|
|||
|
||||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
// `fetchKey` is an explicit re-fetch trigger (escalation bumps it to force
|
||||
// a new request even when documentId/searchSpaceId haven't changed).
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setIsLoading(true);
|
||||
|
|
@ -231,12 +167,6 @@ export function EditorPanelContent({
|
|||
setIsEditing(false);
|
||||
initialLoadDone.current = false;
|
||||
changeCountRef.current = 0;
|
||||
// Clear any in-flight FindReplacePlugin search before the editor
|
||||
// re-mounts on new content (a fresh editor key is generated below
|
||||
// from documentId + isEditing, so the previous editor + its
|
||||
// decorations are about to be discarded anyway, but we belt-and-
|
||||
// brace here for the case where only `fetchKey` changed).
|
||||
clearCitationSearch();
|
||||
|
||||
const doFetch = async () => {
|
||||
try {
|
||||
|
|
@ -281,11 +211,7 @@ export function EditorPanelContent({
|
|||
const url = new URL(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
|
||||
);
|
||||
url.searchParams.set("max_length", String(maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD));
|
||||
// `fetchKey` participates here so biome's noUnusedVariables sees it
|
||||
// as consumed; bumping it forces a fresh request even when the URL
|
||||
// is otherwise identical.
|
||||
if (fetchKey > 0) url.searchParams.set("_n", String(fetchKey));
|
||||
url.searchParams.set("max_length", String(LARGE_DOCUMENT_THRESHOLD));
|
||||
|
||||
const response = await authenticatedFetch(url.toString(), { method: "GET" });
|
||||
|
||||
|
|
@ -331,259 +257,8 @@ export function EditorPanelContent({
|
|||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
title,
|
||||
fetchKey,
|
||||
maxLengthOverride,
|
||||
clearCitationSearch,
|
||||
]);
|
||||
|
||||
// Reset citation-jump bookkeeping whenever the panel switches to a different
|
||||
// document (or local file). Body only writes setters — the deps are the
|
||||
// real triggers we want to react to.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: documentId/localFilePath are intentional triggers.
|
||||
useEffect(() => {
|
||||
clearCitationSearch();
|
||||
escalatedForRef.current = null;
|
||||
lastAppliedChunkIdRef.current = null;
|
||||
setHighlightResult(null);
|
||||
setMaxLengthOverride(null);
|
||||
setFetchKey(0);
|
||||
// Drop sticky Plate mode when the panel moves to a different doc
|
||||
// — the next doc starts in its preferred render mode (Plate for
|
||||
// editable, MarkdownViewer for everything else) until/unless a
|
||||
// citation jump targets it.
|
||||
setStickyPlateMode(false);
|
||||
}, [documentId, localFilePath, clearCitationSearch]);
|
||||
|
||||
// Latch sticky Plate mode the first time a citation jump targets this
|
||||
// doc. We keep it sticky for the remainder of this doc session so the
|
||||
// highlight effect's `setPendingHighlight(null)` doesn't unmount the
|
||||
// editor mid-flight (see comment on `stickyPlateMode` declaration).
|
||||
useEffect(() => {
|
||||
if (isCitationTarget) setStickyPlateMode(true);
|
||||
}, [isCitationTarget]);
|
||||
|
||||
// `isEditorReady` is what `useEffect` actually depends on — `editorRef`
|
||||
// is a ref so changes don't trigger re-runs. We flip this to `true` once
|
||||
// `PlateEditor` calls back with its live editor instance (its
|
||||
// `usePlateEditor` value-init runs synchronously, so by the time this
|
||||
// flips true the markdown is already deserialized into the Slate tree).
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const handleEditorReady = useCallback((editor: PlateEditorInstance | null) => {
|
||||
console.log("[citation:editor] handleEditorReady", { ready: !!editor });
|
||||
editorRef.current = editor;
|
||||
setIsEditorReady(!!editor);
|
||||
}, []);
|
||||
|
||||
// --- Citation jump highlight effect -----------------------------------
|
||||
// Drives Plate's FindReplacePlugin to highlight the cited chunk:
|
||||
// 1. Build candidate snippets from the chunk text (first sentence,
|
||||
// first 8 words, full chunk if short). Plate's decorate runs per-
|
||||
// block and won't cross block boundaries, so the shorter
|
||||
// candidates exist to give us something that fits in one
|
||||
// paragraph / heading.
|
||||
// 2. For each candidate: setOption('search', ...) → redecorate →
|
||||
// wait two animation frames for React to flush → query the editor
|
||||
// DOM for `.${CITATION_HIGHLIGHT_CLASS}`. First hit wins.
|
||||
//
|
||||
// Why a className and not a `data-*` attribute? Plate's
|
||||
// `PlateLeaf` runs its props through `useNodeAttributes`, which
|
||||
// only forwards `attributes`, `className`, `ref`, and `style` —
|
||||
// arbitrary `data-*` attributes are silently dropped. `className`
|
||||
// is the only escape hatch guaranteed to survive into the DOM.
|
||||
// 3. On hit: smooth-scroll the first match into view, mark the
|
||||
// highlight active (so a click inside the editor can dismiss it),
|
||||
// release the pending atom.
|
||||
// 4. On terminal miss: if the doc was truncated and we haven't
|
||||
// escalated yet, bump the fetch's `max_length` to the citation
|
||||
// cap and re-fetch — the post-refetch render will re-run this
|
||||
// effect against the larger preview. Otherwise, release the
|
||||
// atom and show the miss alert.
|
||||
useEffect(() => {
|
||||
console.log("[citation:effect] fired", {
|
||||
isCitationTarget,
|
||||
pendingDocId: pending?.documentId,
|
||||
pendingChunkId: pending?.chunkId,
|
||||
pendingChunkTextLen: pending?.chunkText?.length,
|
||||
documentId,
|
||||
isLocalFileMode,
|
||||
isEditing,
|
||||
hasMarkdown: !!editorDoc?.source_markdown,
|
||||
markdownLen: editorDoc?.source_markdown?.length,
|
||||
truncated: editorDoc?.truncated,
|
||||
isEditorReady,
|
||||
editorRefSet: !!editorRef.current,
|
||||
maxLengthOverride,
|
||||
});
|
||||
if (!isCitationTarget || !pending) {
|
||||
console.log("[citation:effect] guard ✗ no citation target / no pending");
|
||||
return;
|
||||
}
|
||||
if (isLocalFileMode || isEditing) {
|
||||
console.log("[citation:effect] guard ✗ localFileMode/editing");
|
||||
return;
|
||||
}
|
||||
if (!editorDoc?.source_markdown) {
|
||||
console.log("[citation:effect] guard ✗ source_markdown not ready");
|
||||
return;
|
||||
}
|
||||
if (!isEditorReady) {
|
||||
console.log("[citation:effect] guard ✗ editor not ready yet");
|
||||
return;
|
||||
}
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
console.log("[citation:effect] guard ✗ editorRef.current is null");
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastAppliedChunkIdRef.current !== pending.chunkId) {
|
||||
lastAppliedChunkIdRef.current = pending.chunkId;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const finishMiss = () => {
|
||||
console.log("[citation:effect] terminal miss — no candidate matched");
|
||||
try {
|
||||
editor.setOption(FindReplacePlugin, "search", "");
|
||||
editor.api.redecorate();
|
||||
} catch (err) {
|
||||
console.warn("[EditorPanelContent] reset search after miss failed:", err);
|
||||
}
|
||||
const canEscalate =
|
||||
editorDoc.truncated === true &&
|
||||
(maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD) < CITATION_MAX_LENGTH &&
|
||||
escalatedForRef.current !== pending.chunkId;
|
||||
console.log("[citation:effect] miss decision", {
|
||||
truncated: editorDoc.truncated,
|
||||
currentMaxLength: maxLengthOverride ?? LARGE_DOCUMENT_THRESHOLD,
|
||||
canEscalate,
|
||||
});
|
||||
if (canEscalate) {
|
||||
escalatedForRef.current = pending.chunkId;
|
||||
setMaxLengthOverride(CITATION_MAX_LENGTH);
|
||||
setFetchKey((k) => k + 1);
|
||||
// Keep the atom set so the post-refetch render re-runs.
|
||||
return;
|
||||
}
|
||||
setHighlightResult("miss");
|
||||
setPendingHighlight(null);
|
||||
};
|
||||
|
||||
const tryCandidates = async () => {
|
||||
const candidates = buildCitationSearchCandidates(pending.chunkText);
|
||||
console.log("[citation:effect] candidates built", {
|
||||
count: candidates.length,
|
||||
previews: candidates.map((c) => c.slice(0, 60)),
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
if (!cancelled) finishMiss();
|
||||
return;
|
||||
}
|
||||
// Resolve the editor's rendered DOM root via Slate's stable
|
||||
// `[data-slate-editor="true"]` attribute (set by slate-react's
|
||||
// `<Editable>`). Scoping queries to this root prevents
|
||||
// `<mark>` elements rendered elsewhere on the page (e.g. chat
|
||||
// search-highlight leaves in another mounted PlateEditor) from
|
||||
// being mistaken for citation hits.
|
||||
const editorRoot = document.querySelector<HTMLElement>('[data-slate-editor="true"]');
|
||||
console.log("[citation:effect] editor root", {
|
||||
hasRoot: !!editorRoot,
|
||||
});
|
||||
const root: ParentNode = editorRoot ?? document;
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const candidate = candidates[i];
|
||||
if (cancelled) return;
|
||||
try {
|
||||
editor.setOption(FindReplacePlugin, "search", candidate);
|
||||
editor.api.redecorate();
|
||||
console.log(`[citation:effect] try #${i} setOption + redecorate`, {
|
||||
len: candidate.length,
|
||||
preview: candidate.slice(0, 80),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[EditorPanelContent] setOption/redecorate failed:", err);
|
||||
continue;
|
||||
}
|
||||
// Two rAFs: first lets Slate flush its onChange, second lets
|
||||
// React commit the decoration leaves into the DOM.
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
);
|
||||
if (cancelled) return;
|
||||
// Primary probe: by our stable class on the rendered <mark>.
|
||||
let el = root.querySelector<HTMLElement>(`.${CITATION_HIGHLIGHT_CLASS}`);
|
||||
const classMarkCount = root.querySelectorAll(`.${CITATION_HIGHLIGHT_CLASS}`).length;
|
||||
// Diagnostic fallback: any <mark> inside the editor root.
|
||||
// If we ever see allMarks > 0 but classMarkCount === 0,
|
||||
// the className was stripped again and we need to revisit
|
||||
// `useNodeAttributes` filtering.
|
||||
const allMarkCount = root.querySelectorAll("mark").length;
|
||||
if (!el && allMarkCount > 0) {
|
||||
el = root.querySelector<HTMLElement>("mark");
|
||||
}
|
||||
console.log(`[citation:effect] try #${i} DOM probe`, {
|
||||
foundEl: !!el,
|
||||
classMarkCount,
|
||||
allMarkCount,
|
||||
usedFallback: !!el && classMarkCount === 0,
|
||||
});
|
||||
if (el) {
|
||||
try {
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
} catch {
|
||||
el.scrollIntoView();
|
||||
}
|
||||
isHighlightActiveRef.current = true;
|
||||
setHighlightResult("exact");
|
||||
console.log(`[citation:effect] ✓ exact via candidate #${i} — atom released`);
|
||||
// No auto-clear timer — the highlight is intentionally
|
||||
// permanent until the user clicks inside the editor (see
|
||||
// `handleEditorPointerDown`) or another dismissal trigger
|
||||
// fires (doc switch, edit-mode toggle, panel unmount,
|
||||
// next citation jump). Sticky Plate mode keeps the
|
||||
// editor mounted after the atom clears.
|
||||
setPendingHighlight(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!cancelled) finishMiss();
|
||||
};
|
||||
|
||||
void tryCandidates();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
isCitationTarget,
|
||||
pending,
|
||||
documentId,
|
||||
editorDoc?.source_markdown,
|
||||
editorDoc?.truncated,
|
||||
isLocalFileMode,
|
||||
isEditing,
|
||||
isEditorReady,
|
||||
maxLengthOverride,
|
||||
clearCitationSearch,
|
||||
setPendingHighlight,
|
||||
]);
|
||||
|
||||
// Cleanup any active highlight on unmount.
|
||||
useEffect(() => {
|
||||
return () => clearCitationSearch();
|
||||
}, [clearCitationSearch]);
|
||||
|
||||
// Toggling into edit mode swaps Plate out of readOnly. Clear the citation
|
||||
// search so stale leaves don't linger in the editing surface.
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
clearCitationSearch();
|
||||
setHighlightResult(null);
|
||||
}
|
||||
}, [isEditing, clearCitationSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyResetTimeoutRef.current) {
|
||||
|
|
@ -617,7 +292,7 @@ export function EditorPanelContent({
|
|||
}, [editorDoc?.source_markdown]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (_options?: { silent?: boolean }) => {
|
||||
async (options?: { silent?: boolean }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isLocalFileMode) {
|
||||
|
|
@ -668,11 +343,15 @@ export function EditorPanelContent({
|
|||
|
||||
setEditorDoc((prev) => (prev ? { ...prev, source_markdown: markdownRef.current } : prev));
|
||||
setEditedMarkdown(null);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
if (!options?.silent) {
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Error saving document:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save document");
|
||||
if (!options?.silent) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save document");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -693,15 +372,11 @@ export function EditorPanelContent({
|
|||
EDITABLE_DOCUMENT_TYPES.has(editorDoc.document_type ?? "")) &&
|
||||
!isLargeDocument
|
||||
: false;
|
||||
// Use PlateEditor for any of:
|
||||
// - Editable doc types (FILE/NOTE) — existing editing UX.
|
||||
// - Active citation jump in flight (`isCitationTarget`) — covers the
|
||||
// mount in the very first render where the atom is set but the
|
||||
// sticky effect hasn't fired yet.
|
||||
// - Sticky Plate mode latched on a previous citation jump — keeps
|
||||
// the editor mounted (with its decorations) after the highlight
|
||||
// effect clears the atom. Resets when the doc changes.
|
||||
const renderInPlateEditor = isEditableType || isCitationTarget || stickyPlateMode;
|
||||
// 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.
|
||||
const renderInPlateEditor = isEditableType;
|
||||
const hasUnsavedChanges = editedMarkdown !== null;
|
||||
const showDesktopHeader = !!onClose;
|
||||
const showEditingActions = isEditableType && isEditing;
|
||||
|
|
@ -744,36 +419,6 @@ export function EditorPanelContent({
|
|||
}
|
||||
}, [documentId, editorDoc?.title, searchSpaceId]);
|
||||
|
||||
// We no longer surface an "approximate" status — Plate's FindReplacePlugin
|
||||
// either decorates an exact match or it doesn't, and the candidate snippet
|
||||
// strategy (first sentence → first 8 words → full chunk) means we either
|
||||
// land on the citation start or fall through to the miss alert.
|
||||
const showMissAlert = isCitationTarget && highlightResult === "miss";
|
||||
|
||||
const citationAlerts = showMissAlert && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<FileQuestionMark className="size-4" />
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>Cited section couldn't be located in this view.</span>
|
||||
{editorDoc?.truncated && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={handleDownloadMarkdown}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
|
||||
const largeDocAlert = isLargeDocument && !isLocalFileMode && editorDoc && (
|
||||
<Alert className="mb-4">
|
||||
<FileText className="size-4" />
|
||||
|
|
@ -1002,30 +647,17 @@ export function EditorPanelContent({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isLargeDocument && !isLocalFileMode && !isCitationTarget ? (
|
||||
// Large doc, no active citation — fast Streamdown preview
|
||||
// + download CTA. We only fall back to MarkdownViewer here
|
||||
// because Plate is heavy on multi-MB docs and the user
|
||||
// isn't waiting on a specific citation to render.
|
||||
) : 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">
|
||||
{largeDocAlert}
|
||||
<MarkdownViewer content={editorDoc.source_markdown} />
|
||||
</div>
|
||||
) : renderInPlateEditor ? (
|
||||
// Editable doc (FILE/NOTE) OR active citation jump (any
|
||||
// doc type). The citation path uses Plate's
|
||||
// FindReplacePlugin for native, decoration-based
|
||||
// highlighting — see the citation-jump highlight effect
|
||||
// above for how `editorRef` and `handleEditorReady` are
|
||||
// wired.
|
||||
// Editable doc (FILE/NOTE) — Plate editing UX.
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{(citationAlerts || (isLargeDocument && isCitationTarget && !isLocalFileMode)) && (
|
||||
<div className="shrink-0 px-5 pt-4">
|
||||
{isLargeDocument && isCitationTarget && largeDocAlert}
|
||||
{citationAlerts}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden" onPointerDown={handleEditorPointerDown}>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<PlateEditor
|
||||
key={`${isLocalFileMode ? (localFilePath ?? "local-file") : documentId}-${isEditing ? "editing" : "viewing"}`}
|
||||
preset="full"
|
||||
|
|
@ -1037,8 +669,7 @@ export function EditorPanelContent({
|
|||
allowModeToggle={false}
|
||||
reserveToolbarSpace
|
||||
defaultEditing={isEditing}
|
||||
className="[&_[role=toolbar]]:!bg-sidebar"
|
||||
onEditorReady={handleEditorReady}
|
||||
className="**:[[role=toolbar]]:bg-sidebar!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@ import { type EditorPreset, presetMap } from "@/components/editor/presets";
|
|||
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
|
||||
import { Editor, EditorContainer } from "@/components/ui/editor";
|
||||
|
||||
/** Live editor instance returned by `usePlateEditor`. Exposed via the
|
||||
* `onEditorReady` prop so callers (e.g. `EditorPanelContent`) can drive
|
||||
* plugin options imperatively — most notably setting
|
||||
* `FindReplacePlugin`'s `search` option for citation-jump highlights. */
|
||||
/** Live editor instance returned by `usePlateEditor`. */
|
||||
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
|
||||
|
||||
export interface PlateEditorProps {
|
||||
|
|
@ -68,15 +65,6 @@ export interface PlateEditorProps {
|
|||
* without modifying the core editor component.
|
||||
*/
|
||||
extraPlugins?: AnyPluginConfig[];
|
||||
/**
|
||||
* Called whenever the live editor instance (re)mounts, with `null` on
|
||||
* unmount. Used by callers that need to drive plugin options imperatively
|
||||
* — e.g. `EditorPanelContent` setting `FindReplacePlugin`'s `search`
|
||||
* option for citation-jump highlights. The callback is invoked exactly
|
||||
* once per editor lifetime (the parent's `key` prop forces a fresh
|
||||
* editor when needed, e.g. on edit-mode toggle).
|
||||
*/
|
||||
onEditorReady?: (editor: PlateEditorInstance | null) => void;
|
||||
}
|
||||
|
||||
function PlateEditorContent({
|
||||
|
|
@ -115,7 +103,6 @@ export function PlateEditor({
|
|||
defaultEditing = false,
|
||||
preset = "full",
|
||||
extraPlugins = [],
|
||||
onEditorReady,
|
||||
}: PlateEditorProps) {
|
||||
const lastMarkdownRef = useRef(markdown);
|
||||
const lastHtmlRef = useRef(html);
|
||||
|
|
@ -172,21 +159,6 @@ export function PlateEditor({
|
|||
: undefined,
|
||||
});
|
||||
|
||||
// Expose the live editor instance to imperative callers (e.g. citation
|
||||
// jump highlights). We deliberately don't depend on `onEditorReady`
|
||||
// itself in the cleanup closure — callers commonly pass an arrow that
|
||||
// closes over a stable ref setter, but if they pass a freshly-bound
|
||||
// callback per render, the `onEditorReady?.(editor)` re-fires which is
|
||||
// idempotent for ref-style setters.
|
||||
const onEditorReadyRef = useRef(onEditorReady);
|
||||
useEffect(() => {
|
||||
onEditorReadyRef.current = onEditorReady;
|
||||
}, [onEditorReady]);
|
||||
useEffect(() => {
|
||||
onEditorReadyRef.current?.(editor);
|
||||
return () => onEditorReadyRef.current?.(null);
|
||||
}, [editor]);
|
||||
|
||||
// Update editor content when html prop changes externally
|
||||
useEffect(() => {
|
||||
if (html !== undefined && html !== lastHtmlRef.current) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { FindReplacePlugin } from "@platejs/find-replace";
|
||||
import type { AnyPluginConfig } from "platejs";
|
||||
import { TrailingBlockPlugin } from "platejs";
|
||||
|
||||
|
|
@ -18,30 +17,6 @@ import { SelectionKit } from "@/components/editor/plugins/selection-kit";
|
|||
import { SlashCommandKit } from "@/components/editor/plugins/slash-command-kit";
|
||||
import { TableKit } from "@/components/editor/plugins/table-kit";
|
||||
import { ToggleKit } from "@/components/editor/plugins/toggle-kit";
|
||||
import { SearchHighlightLeaf } from "@/components/ui/search-highlight-node";
|
||||
|
||||
/**
|
||||
* Citation-jump highlighter. Re-uses Plate's built-in `FindReplacePlugin`
|
||||
* (decorate-only, no editing surface) to drive the "scroll-to-cited-text"
|
||||
* UX in `EditorPanelContent`. We register it in every preset because:
|
||||
* - Decorate is a no-op when `search` is empty (single getOptions() check
|
||||
* per block), so cost is effectively zero for non-citation viewers.
|
||||
* - Keeping it preset-agnostic means citations work whether the doc is
|
||||
* opened in editable (`full`) or pure-viewer (`readonly`) modes.
|
||||
*
|
||||
* The parent component drives `setOption(FindReplacePlugin, 'search', ...)`
|
||||
* + `editor.api.redecorate()` to trigger highlights, then queries the
|
||||
* editor DOM for `.citation-highlight-leaf` to scroll the first match
|
||||
* into view. (We can't use a `data-*` attribute here — Plate's
|
||||
* `PlateLeaf` runs props through `useNodeAttributes`, which only forwards
|
||||
* `attributes`, `className`, `ref`, `style`; arbitrary `data-*` props are
|
||||
* silently dropped.) See `components/ui/search-highlight-node.tsx` for
|
||||
* the leaf component and `CITATION_HIGHLIGHT_CLASS` constant.
|
||||
*/
|
||||
const CitationFindReplacePlugin = FindReplacePlugin.configure({
|
||||
options: { search: "" },
|
||||
render: { node: SearchHighlightLeaf },
|
||||
});
|
||||
|
||||
/**
|
||||
* Full preset – every plugin kit enabled.
|
||||
|
|
@ -63,7 +38,6 @@ export const fullPreset: AnyPluginConfig[] = [
|
|||
...AutoformatKit,
|
||||
...DndKit,
|
||||
TrailingBlockPlugin,
|
||||
CitationFindReplacePlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -78,7 +52,6 @@ export const minimalPreset: AnyPluginConfig[] = [
|
|||
...LinkKit,
|
||||
...AutoformatKit,
|
||||
TrailingBlockPlugin,
|
||||
CitationFindReplacePlugin,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -95,7 +68,6 @@ export const readonlyPreset: AnyPluginConfig[] = [
|
|||
...CalloutKit,
|
||||
...ToggleKit,
|
||||
...MathKit,
|
||||
CitationFindReplacePlugin,
|
||||
];
|
||||
|
||||
/** All available preset names */
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
|
|||
import { startTransition, useEffect } from "react";
|
||||
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
|
||||
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||
|
|
@ -21,6 +22,14 @@ const EditorPanelContent = dynamic(
|
|||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
const CitationPanelContent = dynamic(
|
||||
() =>
|
||||
import("@/components/citation-panel/citation-panel").then((m) => ({
|
||||
default: m.CitationPanelContent,
|
||||
})),
|
||||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
const HitlEditPanelContent = dynamic(
|
||||
() =>
|
||||
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
|
||||
|
|
@ -69,12 +78,14 @@ export function RightPanelExpandButton() {
|
|||
const reportState = useAtomValue(reportPanelAtom);
|
||||
const editorState = useAtomValue(editorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const citationState = useAtomValue(citationPanelAtom);
|
||||
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||
const editorOpen =
|
||||
editorState.isOpen &&
|
||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen;
|
||||
|
||||
if (!collapsed || !hasContent) return null;
|
||||
|
||||
|
|
@ -98,7 +109,13 @@ export function RightPanelExpandButton() {
|
|||
);
|
||||
}
|
||||
|
||||
const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640, "hitl-edit": 640 } as const;
|
||||
const PANEL_WIDTHS = {
|
||||
sources: 420,
|
||||
report: 640,
|
||||
editor: 640,
|
||||
"hitl-edit": 640,
|
||||
citation: 560,
|
||||
} as const;
|
||||
|
||||
export function RightPanel({ documentsPanel }: RightPanelProps) {
|
||||
const [activeTab] = useAtom(rightPanelTabAtom);
|
||||
|
|
@ -108,6 +125,8 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
const closeEditor = useSetAtom(closeEditorPanelAtom);
|
||||
const hitlEditState = useAtomValue(hitlEditPanelAtom);
|
||||
const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom);
|
||||
const citationState = useAtomValue(citationPanelAtom);
|
||||
const closeCitation = useSetAtom(closeCitationPanelAtom);
|
||||
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||
|
||||
const documentsOpen = documentsPanel?.open ?? false;
|
||||
|
|
@ -116,37 +135,59 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
editorState.isOpen &&
|
||||
(editorState.kind === "document" ? !!editorState.documentId : !!editorState.localFilePath);
|
||||
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
|
||||
const citationOpen = citationState.isOpen && citationState.chunkId != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportOpen && !editorOpen && !hitlEditOpen) return;
|
||||
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (hitlEditOpen) closeHitlEdit();
|
||||
else if (citationOpen) closeCitation();
|
||||
else if (editorOpen) closeEditor();
|
||||
else if (reportOpen) closeReport();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [reportOpen, editorOpen, hitlEditOpen, closeReport, closeEditor, closeHitlEdit]);
|
||||
}, [
|
||||
reportOpen,
|
||||
editorOpen,
|
||||
hitlEditOpen,
|
||||
citationOpen,
|
||||
closeReport,
|
||||
closeEditor,
|
||||
closeHitlEdit,
|
||||
closeCitation,
|
||||
]);
|
||||
|
||||
const isVisible = (documentsOpen || reportOpen || editorOpen || hitlEditOpen) && !collapsed;
|
||||
const isVisible =
|
||||
(documentsOpen || reportOpen || editorOpen || hitlEditOpen || citationOpen) && !collapsed;
|
||||
|
||||
let effectiveTab = activeTab;
|
||||
if (effectiveTab === "hitl-edit" && !hitlEditOpen) {
|
||||
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "editor" && !editorOpen) {
|
||||
effectiveTab = reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "report" && !reportOpen) {
|
||||
effectiveTab = editorOpen ? "editor" : "sources";
|
||||
} else if (effectiveTab === "sources" && !documentsOpen) {
|
||||
effectiveTab = hitlEditOpen
|
||||
? "hitl-edit"
|
||||
effectiveTab = citationOpen
|
||||
? "citation"
|
||||
: editorOpen
|
||||
? "editor"
|
||||
: reportOpen
|
||||
? "report"
|
||||
: "sources";
|
||||
} else if (effectiveTab === "citation" && !citationOpen) {
|
||||
effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "editor" && !editorOpen) {
|
||||
effectiveTab = citationOpen ? "citation" : reportOpen ? "report" : "sources";
|
||||
} else if (effectiveTab === "report" && !reportOpen) {
|
||||
effectiveTab = citationOpen ? "citation" : editorOpen ? "editor" : "sources";
|
||||
} else if (effectiveTab === "sources" && !documentsOpen) {
|
||||
effectiveTab = hitlEditOpen
|
||||
? "hitl-edit"
|
||||
: citationOpen
|
||||
? "citation"
|
||||
: editorOpen
|
||||
? "editor"
|
||||
: reportOpen
|
||||
? "report"
|
||||
: "sources";
|
||||
}
|
||||
|
||||
const targetWidth = PANEL_WIDTHS[effectiveTab];
|
||||
|
|
@ -205,6 +246,11 @@ export function RightPanel({ documentsPanel }: RightPanelProps) {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "citation" && citationOpen && citationState.chunkId != null && (
|
||||
<div className="h-full flex flex-col">
|
||||
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { PlateLeafProps } from "platejs/react";
|
||||
import { PlateLeaf } from "platejs/react";
|
||||
|
||||
/**
|
||||
* Stable class name used to identify Plate-rendered citation highlight
|
||||
* leaves in the DOM. We can't use a `data-*` attribute here — Plate's
|
||||
* `PlateLeaf` runs its props through `useNodeAttributes`, which only
|
||||
* forwards `attributes`, `className`, `ref`, and `style` to the rendered
|
||||
* element; arbitrary `data-*` props are silently dropped (verified
|
||||
* against `@platejs/core/dist/react/index.js` v52). So `className` is
|
||||
* the only escape hatch that's guaranteed to survive into the DOM.
|
||||
*/
|
||||
export const CITATION_HIGHLIGHT_CLASS = "citation-highlight-leaf";
|
||||
|
||||
/**
|
||||
* Leaf rendered for ranges decorated by `@platejs/find-replace`'s
|
||||
* `FindReplacePlugin`. We re-purpose that plugin to drive the citation-jump
|
||||
* highlight: when a citation is staged, the parent sets the plugin's `search`
|
||||
* option to a snippet of the chunk text and Plate decorates every match with
|
||||
* `searchHighlight: true`. This component renders those decorations as a
|
||||
* `<mark>` tagged with `CITATION_HIGHLIGHT_CLASS` so the parent can:
|
||||
* 1. Query the first match in DOM order to scroll it into view.
|
||||
* 2. Detect the active-highlight state without a separate React ref.
|
||||
*
|
||||
* The highlight is **persistent** — it does not auto-fade. The parent in
|
||||
* `EditorPanelContent` clears it by setting the plugin's `search` option
|
||||
* back to "" when one of: (a) the user clicks anywhere inside the editor,
|
||||
* (b) the panel switches to a different document, (c) the user toggles
|
||||
* into edit mode, (d) another citation jump is staged, (e) the panel
|
||||
* unmounts. We use a brief entrance pulse (`citation-flash-in`, see
|
||||
* `globals.css`) purely to draw the eye after `scrollIntoView` lands.
|
||||
*/
|
||||
export function SearchHighlightLeaf(props: PlateLeafProps) {
|
||||
return (
|
||||
<PlateLeaf
|
||||
{...props}
|
||||
as="mark"
|
||||
className={`${CITATION_HIGHLIGHT_CLASS} bg-primary/15 ring-1 ring-primary/40 rounded-sm px-0.5 text-inherit animate-[citation-flash-in_400ms_ease-out]`}
|
||||
>
|
||||
{props.children}
|
||||
</PlateLeaf>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue