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

388 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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}
/>
)}
/>
);