chore: init monorepo snapshot
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
'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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
Reference in New Issue
Block a user