336 lines
11 KiB
TypeScript
336 lines
11 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";
|
|||
|
|
|
|||
|
|
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 }),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
editor.removeBlocks([block.id]);
|
|||
|
|
router.refresh();
|
|||
|
|
}, [block, editor, router]);
|
|||
|
|
|
|||
|
|
const handleDeleteBlock = useCallback(() => {
|
|||
|
|
if (block.type === "pageReference") {
|
|||
|
|
void removePageReference();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
editor.removeBlocks([block.id]);
|
|||
|
|
}, [block, 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: "blockquote" },
|
|||
|
|
{ label: "代码片段", type: "codeBlock" },
|
|||
|
|
],
|
|||
|
|
[turnToPage],
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const convertBlock = useCallback(
|
|||
|
|
(option: ConvertOption) => {
|
|||
|
|
if (option.action) {
|
|||
|
|
option.action();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (!option.type) return;
|
|||
|
|
editor.updateBlock(block, {
|
|||
|
|
type: option.type,
|
|||
|
|
props: option.props ?? {},
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
[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}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
);
|