0.2.1 onlyoffice修复
This commit is contained in:
@@ -21,6 +21,7 @@ import type { MediaAsset } from "@/types/media";
|
||||
import { customBlockSchema, type CustomBlockSchema } from "./schema";
|
||||
import { CustomSideMenu } from "./menus/CustomSideMenu";
|
||||
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
|
||||
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
@@ -777,13 +778,13 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
editable={!pageOptions.protectEditing}
|
||||
className={blocknoteClass}
|
||||
>
|
||||
{!isFullScreenTableOpen && (
|
||||
<SideMenuController
|
||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||
<CustomSideMenu {...props} currentDocumentId={documentId} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isFullScreenTableOpen && (
|
||||
<SideMenuController
|
||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||
</BlockNoteView>
|
||||
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
|
||||
@@ -793,6 +794,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
||||
</div>
|
||||
|
||||
<MoveEmbedPickerHost />
|
||||
|
||||
{/* 全屏表格编辑器 Modal */}
|
||||
{fullScreenTableId && (
|
||||
<FullScreenTableEditor
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import { RiFileTextFill } from "react-icons/ri";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
const normalizeTitle = (value?: string | null) => {
|
||||
if (!value || !value.trim()) {
|
||||
@@ -15,7 +16,7 @@ const normalizeTitle = (value?: string | null) => {
|
||||
|
||||
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
|
||||
const router = useRouter();
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||
const fallbackTitle = normalizeTitle(title);
|
||||
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
|
||||
|
||||
@@ -27,6 +28,10 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
||||
if (!pageId) {
|
||||
return;
|
||||
}
|
||||
if (useConvex) {
|
||||
// 说明:Convex 迁移阶段先不做 title 的实时订阅/拉取,直接使用 block props 里的 title。
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const applyTitle = (nextTitle?: string | null) => {
|
||||
if (!cancelled) {
|
||||
@@ -36,6 +41,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
||||
|
||||
const fetchTitle = async () => {
|
||||
try {
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
const { data } = await supabaseBrowser
|
||||
.from("documents")
|
||||
.select("title")
|
||||
@@ -51,6 +57,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
||||
|
||||
void fetchTitle();
|
||||
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
const channel = supabaseBrowser
|
||||
.channel(`page-ref-${pageId}`)
|
||||
.on(
|
||||
@@ -67,7 +74,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
||||
cancelled = true;
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
}, [pageId]);
|
||||
}, [pageId, useConvex]);
|
||||
|
||||
const navigate = () => {
|
||||
if (pageId) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { deleteOnlineTable } from "@/lib/online-table";
|
||||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
|
||||
type InlineNode = { text?: unknown };
|
||||
type TableMenuBlock = Parameters<
|
||||
@@ -32,6 +33,7 @@ type ConvertOption = {
|
||||
|
||||
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
|
||||
currentDocumentId: string;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
const extractText = (block: Block<CustomBlockSchema>) => {
|
||||
@@ -43,10 +45,11 @@ const extractText = (block: Block<CustomBlockSchema>) => {
|
||||
return "未命名页面";
|
||||
};
|
||||
|
||||
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
|
||||
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
|
||||
const Components = useComponentsContext()!;
|
||||
const editor = useBlockNoteEditor<CustomBlockSchema>();
|
||||
const router = useRouter();
|
||||
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
|
||||
const duplicateBlock = useCallback(() => {
|
||||
const blockWithoutId: DraftBlock = { ...block };
|
||||
@@ -156,39 +159,71 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
||||
router.refresh();
|
||||
}, [block, currentDocumentId, editor, router]);
|
||||
|
||||
const handleMoveEmbedPick = useCallback(
|
||||
async (mode: "move" | "embed", targetDocumentId: string | null) => {
|
||||
if (!targetDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "embed" && targetDocumentId === currentDocumentId) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert("禁止嵌入到当前页面");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const endpoint = mode === "embed" ? "/api/blocks/embed" : "/api/blocks/move";
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
sourceDocumentId: currentDocumentId,
|
||||
blockId: block.id,
|
||||
targetDocumentId,
|
||||
position: "end",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const message =
|
||||
payload?.error ?? (mode === "embed" ? "嵌入失败,请检查目标页面" : "移动失败,请检查目标页面");
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "move") {
|
||||
// 说明:移动块本体:本地编辑器也要移除该块,避免等待刷新造成错觉。
|
||||
try {
|
||||
editor.removeBlocks([block.id]);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert("已在目标页面末尾插入嵌入引用块");
|
||||
}
|
||||
},
|
||||
[block.id, currentDocumentId, editor, router],
|
||||
);
|
||||
|
||||
const moveOrEmbedBlock = useCallback(async () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const targetParent = window.prompt("输入目标页面 ID(将在该页面末尾插入新子页面)", currentDocumentId);
|
||||
if (!targetParent) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/documents/create-child", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parentId: targetParent.trim(),
|
||||
title: extractText(block),
|
||||
blocks: [block],
|
||||
}),
|
||||
// 说明:拖拽菜单点击后会立即卸载,必须使用全局 Host 承载弹窗。
|
||||
openPicker({
|
||||
workspaceId,
|
||||
defaultMode: "move",
|
||||
modes: ["move", "embed"],
|
||||
allowRoot: false,
|
||||
excludeIds: [currentDocumentId],
|
||||
onPick: handleMoveEmbedPick,
|
||||
});
|
||||
if (!response.ok) {
|
||||
window.alert("移动失败,请确认页面 ID");
|
||||
return;
|
||||
}
|
||||
const { pageId, title } = await response.json();
|
||||
editor.replaceBlocks(
|
||||
[block.id],
|
||||
[
|
||||
{
|
||||
type: "pageReference",
|
||||
props: { pageId, title },
|
||||
} as PartialBlock<CustomBlockSchema>,
|
||||
],
|
||||
);
|
||||
router.refresh();
|
||||
}, [block, currentDocumentId, editor, router]);
|
||||
return;
|
||||
}, [currentDocumentId, handleMoveEmbedPick, openPicker, workspaceId]);
|
||||
|
||||
const convertOptions = useMemo<ConvertOption[]>(
|
||||
() => [
|
||||
@@ -372,6 +407,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) =>
|
||||
|
||||
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
||||
currentDocumentId: string;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||||
@@ -381,6 +417,7 @@ export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||||
<CustomDragHandleMenu
|
||||
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
|
||||
currentDocumentId={props.currentDocumentId}
|
||||
workspaceId={props.workspaceId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { progressBlock } from "./blocks/ProgressBlock";
|
||||
import { mediaBlock } from "./blocks/MediaBlock";
|
||||
import { onlineTableBlock } from "./blocks/OnlineTableBlock";
|
||||
import { mindmapBlock } from "./blocks/MindmapBlock";
|
||||
import { blockReferenceBlock } from "./blocks/BlockReferenceBlock";
|
||||
|
||||
const headingSpec = createHeadingBlockSpec({
|
||||
levels: [1, 2, 3, 4, 5],
|
||||
@@ -24,6 +25,7 @@ export const customBlockSchema = BlockNoteSchema.create({
|
||||
...defaultBlockSpecs,
|
||||
heading: headingSpec,
|
||||
pageReference: pageReferenceBlock,
|
||||
blockReference: blockReferenceBlock,
|
||||
advancedTodo: advancedTodoBlock,
|
||||
progressMeter: progressBlock,
|
||||
media: mediaBlock,
|
||||
|
||||
Reference in New Issue
Block a user