feat(markdown): enable citation rendering in MarkdownViewer and related components

- Added `enableCitations` prop to `MarkdownViewer` to support interactive citation badges.
- Updated instances of `MarkdownViewer` across various components to utilize the new citation feature.
- Enhanced citation processing in `PlateEditor` for read-only views, ensuring citations are rendered correctly without affecting markdown serialization.
- Refactored citation handling in `InlineCitation` and `MarkdownText` to improve citation context management.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-30 18:40:55 -07:00
parent d335e96ec2
commit 7aeb8bb0a8
14 changed files with 809 additions and 260 deletions

View file

@ -8,9 +8,11 @@ import { useEffect, useMemo, useRef } from "react";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { EditorSaveContext } from "@/components/editor/editor-save-context";
import { CitationKit, injectCitationNodes } from "@/components/editor/plugins/citation-kit";
import { type EditorPreset, presetMap } from "@/components/editor/presets";
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
import { Editor, EditorContainer } from "@/components/ui/editor";
import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
/** Live editor instance returned by `usePlateEditor`. */
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
@ -65,6 +67,14 @@ export interface PlateEditorProps {
* without modifying the core editor component.
*/
extraPlugins?: AnyPluginConfig[];
/**
* Render `[citation:N]` and `[citation:URL]` tokens in the deserialized
* markdown as interactive citation badges/popovers (mirrors chat). Only
* meant for read-only views when true, `onMarkdownChange` is suppressed
* because the in-memory tree contains custom inline-void elements that
* have no markdown serialize rule.
*/
enableCitations?: boolean;
}
function PlateEditorContent({
@ -103,6 +113,7 @@ export function PlateEditor({
defaultEditing = false,
preset = "full",
extraPlugins = [],
enableCitations = false,
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
const lastHtmlRef = useRef(html);
@ -145,6 +156,8 @@ export function PlateEditor({
...(onSave ? [SaveShortcutPlugin] : []),
// Consumer-provided extra plugins
...extraPlugins,
// Citation void inline element (read-only document viewer).
...(enableCitations ? CitationKit : []),
MarkdownPlugin.configure({
options: {
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
@ -154,8 +167,18 @@ export function PlateEditor({
value: html
? (editor) => editor.api.html.deserialize({ element: html }) as Value
: markdown
? (editor) =>
editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
? (editor) => {
if (!enableCitations) {
return editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
}
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const value = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten));
return injectCitationNodes(value as Descendant[], urlMap) as Value;
}
: undefined,
});
@ -174,13 +197,22 @@ export function PlateEditor({
useEffect(() => {
if (!html && markdown !== undefined && markdown !== lastMarkdownRef.current) {
lastMarkdownRef.current = markdown;
const newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
let newValue: Descendant[];
if (enableCitations) {
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const deserialized = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten)) as Descendant[];
newValue = injectCitationNodes(deserialized, urlMap);
} else {
newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown)) as Descendant[];
}
editor.tf.reset();
editor.tf.setValue(newValue);
editor.tf.setValue(newValue as Value);
}
}, [html, markdown, editor]);
}, [html, markdown, editor, enableCitations]);
// When not forced read-only, the user can toggle between editing/viewing.
const canToggleMode = !readOnly && allowModeToggle;
@ -205,6 +237,16 @@ export function PlateEditor({
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
{...(readOnly ? { readOnly: true } : {})}
onChange={({ value }) => {
// View-only citation mode: skip serialization. The custom
// `citation` inline-void element has no markdown serialize
// rule, so emitting changes here would overwrite
// `lastMarkdownRef.current` (and downstream copy-to-clipboard
// state in EditorPanelContent) with a tree that loses every
// citation token. `enableCitations` is only ever set in
// read-only paths, so user input cannot reach this branch
// in practice — the guard exists for the initial Plate
// normalize emit.
if (enableCitations) return;
if (onHtmlChange && html) {
const serialized = slateToHtml(value as Descendant[]);
onHtmlChange(serialized);