167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
|
import { createReactBlockSpec } from "@blocknote/react";
|
||
|
|
import { useRouter } from "next/navigation";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { extractBlockText } from "@/lib/blocks";
|
||
|
|
|
||
|
|
type RemoteBlock = {
|
||
|
|
id: string;
|
||
|
|
type?: string;
|
||
|
|
props?: Record<string, unknown>;
|
||
|
|
content?: unknown;
|
||
|
|
children?: unknown;
|
||
|
|
};
|
||
|
|
|
||
|
|
const isTextBlock = (block: RemoteBlock) => block.type === "paragraph" || block.type === "heading";
|
||
|
|
|
||
|
|
export const blockReferenceBlock = createReactBlockSpec(
|
||
|
|
{
|
||
|
|
type: "blockReference",
|
||
|
|
propSchema: {
|
||
|
|
sourceDocumentId: { default: "" },
|
||
|
|
targetBlockId: { default: "" },
|
||
|
|
display: { default: "embed" },
|
||
|
|
},
|
||
|
|
content: "none",
|
||
|
|
},
|
||
|
|
() => ({
|
||
|
|
render: ({ block }) => <BlockReferenceContent block={block as any} />,
|
||
|
|
}),
|
||
|
|
)();
|
||
|
|
|
||
|
|
function BlockReferenceContent({ block }: { block: { props: { sourceDocumentId: string; targetBlockId: string } } }) {
|
||
|
|
const router = useRouter();
|
||
|
|
const sourceDocumentId = block.props.sourceDocumentId;
|
||
|
|
const targetBlockId = block.props.targetBlockId;
|
||
|
|
|
||
|
|
const [remote, setRemote] = useState<RemoteBlock | null>(null);
|
||
|
|
const [textDraft, setTextDraft] = useState<string>("");
|
||
|
|
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
|
||
|
|
const [error, setError] = useState<string>("");
|
||
|
|
|
||
|
|
const canEdit = useMemo(() => Boolean(remote && isTextBlock(remote)), [remote]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!sourceDocumentId || !targetBlockId) {
|
||
|
|
setStatus("error");
|
||
|
|
setError("引用信息不完整");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let cancelled = false;
|
||
|
|
setStatus("loading");
|
||
|
|
setError("");
|
||
|
|
setRemote(null);
|
||
|
|
|
||
|
|
const run = async () => {
|
||
|
|
try {
|
||
|
|
const res = await fetch(
|
||
|
|
`/api/blocks/get?sourceDocumentId=${encodeURIComponent(sourceDocumentId)}&blockId=${encodeURIComponent(targetBlockId)}`,
|
||
|
|
{ method: "GET", credentials: "include" },
|
||
|
|
);
|
||
|
|
if (!res.ok) {
|
||
|
|
const payload = await res.json().catch(() => ({}));
|
||
|
|
throw new Error(payload?.error ?? "获取引用块失败");
|
||
|
|
}
|
||
|
|
const json = await res.json();
|
||
|
|
const next = (json?.block ?? null) as RemoteBlock | null;
|
||
|
|
if (!cancelled) {
|
||
|
|
setRemote(next);
|
||
|
|
if (next && isTextBlock(next)) {
|
||
|
|
setTextDraft(extractBlockText(next as any));
|
||
|
|
}
|
||
|
|
setStatus("idle");
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
if (!cancelled) {
|
||
|
|
setStatus("error");
|
||
|
|
setError(e instanceof Error ? e.message : "获取引用块失败");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
void run();
|
||
|
|
return () => {
|
||
|
|
cancelled = true;
|
||
|
|
};
|
||
|
|
}, [sourceDocumentId, targetBlockId]);
|
||
|
|
|
||
|
|
const openSource = useCallback(() => {
|
||
|
|
if (sourceDocumentId) {
|
||
|
|
router.push(`/documents/${sourceDocumentId}`);
|
||
|
|
}
|
||
|
|
}, [router, sourceDocumentId]);
|
||
|
|
|
||
|
|
const saveText = useCallback(async () => {
|
||
|
|
if (!remote || !canEdit) return;
|
||
|
|
const nextBlock: RemoteBlock = {
|
||
|
|
...remote,
|
||
|
|
id: remote.id,
|
||
|
|
content: [{ type: "text", text: textDraft }],
|
||
|
|
};
|
||
|
|
const res = await fetch("/api/blocks/patch", {
|
||
|
|
method: "POST",
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
credentials: "include",
|
||
|
|
body: JSON.stringify({ sourceDocumentId, blockId: targetBlockId, nextBlock }),
|
||
|
|
});
|
||
|
|
if (!res.ok) {
|
||
|
|
const payload = await res.json().catch(() => ({}));
|
||
|
|
const msg = payload?.error ?? "同步编辑失败";
|
||
|
|
if (typeof window !== "undefined") window.alert(msg);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (typeof window !== "undefined") window.alert("已同步编辑到原块");
|
||
|
|
}, [canEdit, remote, sourceDocumentId, targetBlockId, textDraft]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className="mt-2 rounded-md border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3"
|
||
|
|
onMouseDown={(e) => {
|
||
|
|
// 说明:避免点击内部按钮/输入框时误触发编辑器的拖拽/选择。
|
||
|
|
e.stopPropagation();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="mb-2 flex items-center gap-2 text-xs text-gray-500">
|
||
|
|
<span>嵌入引用</span>
|
||
|
|
<span className="ml-auto flex items-center gap-2">
|
||
|
|
<Button type="button" size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={openSource}>
|
||
|
|
打开原块
|
||
|
|
</Button>
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{status === "loading" ? (
|
||
|
|
<div className="text-sm text-gray-400">加载中...</div>
|
||
|
|
) : status === "error" ? (
|
||
|
|
<div className="text-sm text-red-600">{error}</div>
|
||
|
|
) : !remote ? (
|
||
|
|
<div className="text-sm text-gray-400">引用块不存在</div>
|
||
|
|
) : canEdit ? (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<textarea
|
||
|
|
className="w-full resize-y rounded-md border border-[#e2e8f0] bg-white p-2 text-sm text-gray-900 outline-none"
|
||
|
|
rows={3}
|
||
|
|
value={textDraft}
|
||
|
|
onChange={(e) => setTextDraft(e.target.value)}
|
||
|
|
placeholder="在这里编辑会同步到原块(MVP:仅支持段落/标题纯文本)"
|
||
|
|
/>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button type="button" size="sm" className="h-8 px-3 text-xs" onClick={() => void saveText()}>
|
||
|
|
同步到原块
|
||
|
|
</Button>
|
||
|
|
<span className="text-[11px] text-gray-400">MVP:仅支持段落/标题纯文本同步</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="text-sm text-gray-700">
|
||
|
|
<div className="mb-1 text-xs text-gray-400">当前块类型:{remote.type ?? "unknown"}</div>
|
||
|
|
<div className="text-sm text-gray-800">{extractBlockText(remote as any) || "(内容为空或暂不支持渲染)"}</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|