Files
mnote/wolai-frontend/src/components/editor/menus/CustomSideMenu.tsx
T

383 lines
13 KiB
TypeScript
Raw Normal View History

2025-11-23 10:55:04 +08:00
'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";
2025-11-23 20:04:29 +08:00
import { deleteOnlineTable } from "@/lib/online-table";
2026-01-02 07:25:50 +08:00
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
2025-11-23 10:55:04 +08:00
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 }),
});
2026-01-02 07:25:50 +08:00
if (typeof window !== "undefined") {
emitDocumentsChanged(pageId);
}
2025-11-23 10:55:04 +08:00
}
}
editor.removeBlocks([block.id]);
router.refresh();
}, [block, editor, router]);
2026-01-02 07:25:50 +08:00
const handleDeleteBlock = useCallback(async () => {
2025-11-23 10:55:04 +08:00
if (block.type === "pageReference") {
void removePageReference();
return;
}
2025-11-23 20:04:29 +08:00
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 } }));
2026-01-02 07:25:50 +08:00
emitAssetsChanged(currentDocumentId);
2025-11-23 20:04:29 +08:00
}
}
2026-01-02 07:25:50 +08:00
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}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
emitAssetsChanged(currentDocumentId);
editor.removeBlocks([block.id]);
return;
2025-11-23 20:04:29 +08:00
}
2025-11-23 10:55:04 +08:00
editor.removeBlocks([block.id]);
2026-01-02 07:25:50 +08:00
}, [block, currentDocumentId, editor, removePageReference]);
2025-11-23 10:55:04 +08:00
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}
/>
)}
/>
);