388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useMemo } from "react";
|
||
import type { Block, PartialBlock } from "@blocknote/core";
|
||
import {
|
||
BlockColorsItem,
|
||
SideMenu,
|
||
TableColumnHeaderItem,
|
||
TableRowHeaderItem,
|
||
useBlockNoteEditor,
|
||
useComponentsContext,
|
||
type DragHandleMenuProps,
|
||
type SideMenuProps,
|
||
} from "@blocknote/react";
|
||
import { useRouter } from "next/navigation";
|
||
import type { CustomBlockSchema } from "../schema";
|
||
import { deleteOnlineTable } from "@/lib/online-table";
|
||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||
|
||
type InlineNode = { text?: unknown };
|
||
type TableMenuBlock = Parameters<
|
||
typeof TableRowHeaderItem
|
||
>[0]["block"];
|
||
type DraftBlock = PartialBlock<CustomBlockSchema> & { id?: string };
|
||
type ConvertOption = {
|
||
label: string;
|
||
type?: Block<CustomBlockSchema>["type"];
|
||
props?: Record<string, unknown>;
|
||
shortcut?: string;
|
||
action?: () => void;
|
||
};
|
||
|
||
type CustomDragProps = DragHandleMenuProps<CustomBlockSchema> & {
|
||
currentDocumentId: string;
|
||
};
|
||
|
||
const extractText = (block: Block<CustomBlockSchema>) => {
|
||
const inlineNodes = block.content as InlineNode[] | undefined;
|
||
const maybeText = inlineNodes?.[0]?.text;
|
||
if (typeof maybeText === "string" && maybeText.trim().length > 0) {
|
||
return maybeText.trim();
|
||
}
|
||
return "未命名页面";
|
||
};
|
||
|
||
const CustomDragHandleMenu = ({ block, currentDocumentId }: CustomDragProps) => {
|
||
const Components = useComponentsContext()!;
|
||
const editor = useBlockNoteEditor<CustomBlockSchema>();
|
||
const router = useRouter();
|
||
|
||
const duplicateBlock = useCallback(() => {
|
||
const blockWithoutId: DraftBlock = { ...block };
|
||
delete blockWithoutId.id;
|
||
editor.insertBlocks([blockWithoutId], block, "after");
|
||
}, [block, editor]);
|
||
|
||
const removePageReference = useCallback(async () => {
|
||
if (block.type === "pageReference") {
|
||
const pageId = block.props.pageId;
|
||
if (pageId) {
|
||
await fetch("/api/documents/delete", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ documentId: pageId }),
|
||
});
|
||
if (typeof window !== "undefined") {
|
||
emitDocumentsChanged(pageId);
|
||
}
|
||
}
|
||
}
|
||
editor.removeBlocks([block.id]);
|
||
router.refresh();
|
||
}, [block, editor, router]);
|
||
|
||
const handleDeleteBlock = useCallback(async () => {
|
||
if (block.type === "pageReference") {
|
||
void removePageReference();
|
||
return;
|
||
}
|
||
if (block.type === "onlineTable") {
|
||
const tableId = block.props.tableId as string | undefined;
|
||
if (tableId) {
|
||
void deleteOnlineTable(tableId)
|
||
.catch((error) => console.error("删除在线表格失败", error));
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
||
emitAssetsChanged(currentDocumentId);
|
||
}
|
||
}
|
||
editor.removeBlocks([block.id]);
|
||
return;
|
||
}
|
||
if (block.type === "media") {
|
||
const assetId = block.props.assetId as string | undefined;
|
||
if (assetId) {
|
||
const resp = await fetch("/api/media/batch", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
|
||
});
|
||
if (!resp.ok) {
|
||
const payload = await resp.json().catch(() => ({}));
|
||
window.alert(payload?.error ?? "删除附件失败");
|
||
return;
|
||
}
|
||
emitAssetsChanged(currentDocumentId);
|
||
}
|
||
editor.removeBlocks([block.id]);
|
||
return;
|
||
}
|
||
if (block.type === "mindmap") {
|
||
const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" });
|
||
if (!resp.ok) {
|
||
const payload = await resp.json().catch(() => ({}));
|
||
window.alert(payload?.error ?? "删除思维导图失败");
|
||
return;
|
||
}
|
||
emitAssetsChanged(currentDocumentId, undefined, undefined, false, [block.id]);
|
||
// 侧边栏/全局删除监听也会尝试移除对应块,这里做 try/catch 避免重复删除导致报错
|
||
try {
|
||
editor.removeBlocks([block.id]);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return;
|
||
}
|
||
editor.removeBlocks([block.id]);
|
||
}, [block, currentDocumentId, editor, removePageReference]);
|
||
|
||
const turnToPage = useCallback(async () => {
|
||
const response = await fetch("/api/documents/create-child", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
parentId: currentDocumentId,
|
||
title: extractText(block),
|
||
blocks: [block],
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
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]);
|
||
|
||
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],
|
||
}),
|
||
});
|
||
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]);
|
||
|
||
const convertOptions = useMemo<ConvertOption[]>(
|
||
() => [
|
||
{ label: "文本", type: "paragraph", shortcut: "Ctrl+Alt+0" },
|
||
{ label: "待办列表", type: "checkListItem", shortcut: "Ctrl+Shift+5" },
|
||
{ label: "高级待办列表", type: "advancedTodo" },
|
||
{ label: "主标题", type: "heading", props: { level: 1 }, shortcut: "Ctrl+Shift+1" },
|
||
{ label: "大标题", type: "heading", props: { level: 2 }, shortcut: "Ctrl+Shift+2" },
|
||
{ label: "中标题", type: "heading", props: { level: 3 }, shortcut: "Ctrl+Shift+3" },
|
||
{ label: "小标题", type: "heading", props: { level: 4 }, shortcut: "Ctrl+Shift+4" },
|
||
{ label: "页面", action: turnToPage },
|
||
{ label: "列表", type: "bulletListItem", shortcut: "Ctrl+Shift+6" },
|
||
{ label: "数字列表", type: "numberedListItem", shortcut: "Ctrl+Shift+7" },
|
||
{ label: "折叠列表", type: "toggleListItem", shortcut: "Ctrl+Shift+8" },
|
||
{ label: "折叠标题", type: "heading", props: { level: 2, isToggleable: true } },
|
||
{ label: "引述文字", type: "quote" },
|
||
{ label: "代码片段", type: "codeBlock" },
|
||
],
|
||
[turnToPage],
|
||
);
|
||
|
||
const convertBlock = useCallback(
|
||
(option: ConvertOption) => {
|
||
if (option.action) {
|
||
option.action();
|
||
return;
|
||
}
|
||
if (!option.type) return;
|
||
editor.updateBlock(block as any, {
|
||
type: option.type as any,
|
||
props: (option.props ?? {}) as any,
|
||
} as any);
|
||
},
|
||
[block, editor],
|
||
);
|
||
|
||
const copyBlockLink = useCallback(async () => {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
const url = `${window.location.origin}/documents/${currentDocumentId}#block-${block.id}`;
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
window.alert("块链接已复制");
|
||
} catch {
|
||
window.prompt("复制失败,请手动复制", url);
|
||
}
|
||
}, [block.id, currentDocumentId]);
|
||
|
||
const openOnRight = useCallback(() => {
|
||
if (typeof window === "undefined") {
|
||
return;
|
||
}
|
||
const url = `${window.location.origin}/documents/${currentDocumentId}?preview=sidebar&focus=${block.id}`;
|
||
window.open(url, "_blank", "noopener,noreferrer");
|
||
}, [block.id, currentDocumentId]);
|
||
|
||
const setAdvancedTodoStatus = useCallback(
|
||
(status: "todo" | "doing" | "done" | "cancelled") => {
|
||
if (block.type !== "advancedTodo") return;
|
||
editor.updateBlock(block, {
|
||
props: { status },
|
||
});
|
||
},
|
||
[block, editor],
|
||
);
|
||
|
||
const toggleProgressMode = useCallback(() => {
|
||
if (block.type !== "progressMeter") return;
|
||
editor.updateBlock(block, {
|
||
props: { auto: !block.props.auto },
|
||
});
|
||
}, [block, editor]);
|
||
|
||
const setManualProgress = useCallback(() => {
|
||
if (block.type !== "progressMeter") return;
|
||
const value = Number.parseInt(window.prompt("手动设置进度(0-100)", String(block.props.percent ?? 0)) ?? "", 10);
|
||
if (Number.isNaN(value)) return;
|
||
const clamped = Math.min(100, Math.max(0, value));
|
||
editor.updateBlock(block, {
|
||
props: { percent: clamped },
|
||
});
|
||
}, [block, editor]);
|
||
|
||
return (
|
||
<Components.Generic.Menu.Dropdown className="bn-menu-dropdown bn-drag-handle-menu">
|
||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={openOnRight}>
|
||
在右侧边栏打开
|
||
</Components.Generic.Menu.Item>
|
||
|
||
<Components.Generic.Menu.Root sub>
|
||
<Components.Generic.Menu.Trigger sub>
|
||
<Components.Generic.Menu.Item className="bn-menu-item" subTrigger>
|
||
转换为
|
||
</Components.Generic.Menu.Item>
|
||
</Components.Generic.Menu.Trigger>
|
||
<Components.Generic.Menu.Dropdown sub className="bn-menu-dropdown">
|
||
{convertOptions.map((option) => (
|
||
<Components.Generic.Menu.Item
|
||
key={option.label}
|
||
className="bn-menu-item flex items-center justify-between gap-4"
|
||
onClick={() => convertBlock(option)}
|
||
>
|
||
<span>{option.label}</span>
|
||
{option.shortcut && <span className="text-[10px] text-gray-400">{option.shortcut}</span>}
|
||
</Components.Generic.Menu.Item>
|
||
))}
|
||
</Components.Generic.Menu.Dropdown>
|
||
</Components.Generic.Menu.Root>
|
||
|
||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={duplicateBlock}>
|
||
拷贝副本
|
||
</Components.Generic.Menu.Item>
|
||
|
||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={copyBlockLink}>
|
||
复制链接
|
||
</Components.Generic.Menu.Item>
|
||
|
||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={moveOrEmbedBlock}>
|
||
移动/嵌入到...
|
||
</Components.Generic.Menu.Item>
|
||
|
||
<Components.Generic.Menu.Item
|
||
className="bn-menu-item"
|
||
onClick={() => window.alert("块历史功能开发中,敬请期待")}
|
||
>
|
||
块历史...
|
||
</Components.Generic.Menu.Item>
|
||
|
||
<Components.Generic.Menu.Item
|
||
className="bn-menu-item"
|
||
onClick={() => window.alert("评论功能暂未开放")}
|
||
>
|
||
评论
|
||
</Components.Generic.Menu.Item>
|
||
|
||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={handleDeleteBlock}>
|
||
删除
|
||
</Components.Generic.Menu.Item>
|
||
|
||
<BlockColorsItem block={block}>颜色</BlockColorsItem>
|
||
<TableRowHeaderItem block={block as TableMenuBlock}>表头(行)</TableRowHeaderItem>
|
||
<TableColumnHeaderItem block={block as TableMenuBlock}>表头(列)</TableColumnHeaderItem>
|
||
|
||
{block.type === "advancedTodo" && (
|
||
<>
|
||
<Components.Generic.Menu.Divider className="bn-menu-divider" />
|
||
{[
|
||
{ label: "设为未开始", status: "todo" as const },
|
||
{ label: "设为进行中", status: "doing" as const },
|
||
{ label: "设为已完成", status: "done" as const },
|
||
{ label: "设为取消", status: "cancelled" as const },
|
||
].map((item) => (
|
||
<Components.Generic.Menu.Item
|
||
key={item.status}
|
||
className="bn-menu-item"
|
||
onClick={() => setAdvancedTodoStatus(item.status)}
|
||
>
|
||
{item.label}
|
||
</Components.Generic.Menu.Item>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{block.type === "progressMeter" && (
|
||
<>
|
||
<Components.Generic.Menu.Divider className="bn-menu-divider" />
|
||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={toggleProgressMode}>
|
||
{block.props.auto ? "切换为手动进度" : "切换为自动进度"}
|
||
</Components.Generic.Menu.Item>
|
||
{!block.props.auto && (
|
||
<Components.Generic.Menu.Item className="bn-menu-item" onClick={setManualProgress}>
|
||
手动设置百分比
|
||
</Components.Generic.Menu.Item>
|
||
)}
|
||
</>
|
||
)}
|
||
</Components.Generic.Menu.Dropdown>
|
||
);
|
||
};
|
||
|
||
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
||
currentDocumentId: string;
|
||
};
|
||
|
||
export const CustomSideMenu = (props: CustomSideMenuProps) => (
|
||
<SideMenu
|
||
{...props}
|
||
dragHandleMenu={(dragProps) => (
|
||
<CustomDragHandleMenu
|
||
{...(dragProps as DragHandleMenuProps<CustomBlockSchema>)}
|
||
currentDocumentId={props.currentDocumentId}
|
||
/>
|
||
)}
|
||
/>
|
||
);
|