217 lines
8.1 KiB
Markdown
217 lines
8.1 KiB
Markdown
```markdown
|
||||
|
|
# 阶段 1.5:精确克隆 Wolai「转为页面」+「子页面嵌入块」功能(从纯 Stage1 结束状态开始)
|
|||
|
|
|
|||
|
|
**当前前提**:
|
|||
|
|
你刚完成 Stage1(递归侧边栏 + BlockNote 编辑器 + 实时保存 + 面包屑 + 底部栏),还没有任何自定义块、没有 /api/documents/create、没有转页面逻辑。
|
|||
|
|
|
|||
|
|
**目标**:3-4 小时内(AI 助手 < 90 分钟)100% 还原 Wolai 以下三个核心交互(见你提供的最新三张图):
|
|||
|
|
|
|||
|
|
1. 点击块左侧 ::: 柄 → 弹出菜单 → 最上方「转换为」子菜单 → 出现「页面」选项 → 点击后当前块内容变成独立子页面,并在原位置留下蓝色可点击子页面块
|
|||
|
|
2. / 斜杠命令菜单中出现「页面」选项(直接插入或转换)
|
|||
|
|
3. 父页面中所有子页面块正确显示为蓝色标题 + 文件图标,点击跳转,侧边栏自动缩进显示
|
|||
|
|
|
|||
|
|
**验收标准**(运行后必须全部通过):
|
|||
|
|
- 任意段落块点击左侧 ::: → 菜单里有「转换为 → 页面」
|
|||
|
|
- 点击「页面」后:当前块内容立即变成新子页面,父页面该位置出现蓝色子页面块(带图标)
|
|||
|
|
- 侧边栏实时出现新子页面(缩进正确)
|
|||
|
|
- 点击子页面块 → 正常跳转到子页面编辑
|
|||
|
|
- 所有操作实时、无刷新
|
|||
|
|
|
|||
|
|
## 1. 先创建 API:创建子页面(必须第一步)
|
|||
|
|
|
|||
|
|
```ts
|
|||
|
|
// src/app/api/documents/create-child/route.ts (POST)
|
|||
|
|
import { createSupabaseServer } from '@/lib/supabase/server';
|
|||
|
|
import { NextRequest } from 'next/server';
|
|||
|
|
|
|||
|
|
export async function POST(req: NextRequest) {
|
|||
|
|
const supabase = createSupabaseServer();
|
|||
|
|
const { data: { user } } = await supabase.auth.getUser();
|
|||
|
|
if (!user) return new Response('Unauthorized', { status: 401 });
|
|||
|
|
|
|||
|
|
const { parentId, title, blocks } = await req.json(); // blocks = 当前块内容
|
|||
|
|
|
|||
|
|
const { data: newDoc, error } = await supabase
|
|||
|
|
.from('documents')
|
|||
|
|
.insert({
|
|||
|
|
user_id: user.id,
|
|||
|
|
parent_id: parentId, // 关键:建立父子关系
|
|||
|
|
title: title || '未命名页面',
|
|||
|
|
content: { blocks: blocks ?? [] }, // 把原块内容整个迁移过去
|
|||
|
|
})
|
|||
|
|
.select('id, title')
|
|||
|
|
.single();
|
|||
|
|
|
|||
|
|
if (error) return new Response(error.message, { status: 500 });
|
|||
|
|
|
|||
|
|
return Response.json({
|
|||
|
|
pageId: newDoc.id,
|
|||
|
|
title: newDoc.title,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 2. 自定义「子页面块」渲染(精确 Wolai 蓝色样式)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// src/components/editor/blocks/PageReferenceBlock.tsx
|
|||
|
|
import { RiFileTextFill } from "react-icons/ri";
|
|||
|
|
import { useRouter } from "next/navigation";
|
|||
|
|
|
|||
|
|
export const pageReferenceBlock = {
|
|||
|
|
type: "pageReference" as const,
|
|||
|
|
propSchema: {
|
|||
|
|
pageId: { default: "" },
|
|||
|
|
title: { default: "未命名页面" },
|
|||
|
|
},
|
|||
|
|
render: (block: any) => {
|
|||
|
|
const router = useRouter();
|
|||
|
|
const title = block.props.title;
|
|||
|
|
const pageId = block.props.pageId;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
onClick={() => router.push(`/documents/${pageId}`)}
|
|||
|
|
className="flex items-center gap-3 px-4 py-3 my-2 bg-blue-50 border-l-4 border-[#2563eb] rounded-r cursor-pointer hover:bg-blue-100 transition-colors group"
|
|||
|
|
>
|
|||
|
|
<RiFileTextFill className="text-[#2563eb] text-xl flex-shrink-0" />
|
|||
|
|
<span className="text-[#2563eb] font-medium text-base">{title}</span>
|
|||
|
|
<span className="ml-auto text-sm text-[#2563eb] opacity-0 group-hover:opacity-100">
|
|||
|
|
点击进入 →
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 3. 在 BlockNote Schema 中注册自定义块
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// src/components/editor/schema.ts (新建文件)
|
|||
|
|
import { defaultBlockSchema } from "@blocknote/core";
|
|||
|
|
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";
|
|||
|
|
|
|||
|
|
export const customSchema = {
|
|||
|
|
...defaultBlockSchema,
|
|||
|
|
pageReference: pageReferenceBlock,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// src/components/editor/BlockNoteEditor.tsx (修改 useCreateBlockNote)
|
|||
|
|
import { customSchema } from "./schema";
|
|||
|
|
|
|||
|
|
const editor = useCreateBlockNote({
|
|||
|
|
initialContent: initialContent,
|
|||
|
|
schema: customSchema, // ← 关键
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 4. 核心:自定义 SideMenu(::: 柄菜单)添加「转换为 → 页面」
|
|||
|
|
|
|||
|
|
BlockNote 官方支持完全自定义 SideMenu:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// src/components/editor/menus/CustomSideMenu.tsx
|
|||
|
|
'use client';
|
|||
|
|
import { BlockNoteSideMenu from "@blocknote/react/side-menu";
|
|||
|
|
import { HiOutlineDocumentDuplicate, HiOutlineTrash, HiOutlineColorSwatch } from "react-icons/hi";
|
|||
|
|
import { MdOutlineSubdirectoryArrowRight } from "react-icons/md";
|
|||
|
|
|
|||
|
|
export const CustomSideMenu = (props: { editor: any; currentDocumentId: string }) => {
|
|||
|
|
const { editor, currentDocumentId } = props;
|
|||
|
|
|
|||
|
|
const turnToPage = async () => {
|
|||
|
|
const block = editor.getSelectedBlock();
|
|||
|
|
if (!block) return;
|
|||
|
|
|
|||
|
|
const res = await fetch("/api/documents/create-child", {
|
|||
|
|
method: "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
parentId: currentDocumentId,
|
|||
|
|
title: block.content?.[0]?.text || "未命名页面",
|
|||
|
|
blocks: [block], // 把整个块内容传过去
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const { pageId, title } = await res.json();
|
|||
|
|
|
|||
|
|
// 替换当前块为子页面引用块
|
|||
|
|
editor.updateBlock(block, {
|
|||
|
|
type: "pageReference",
|
|||
|
|
props: { pageId, title },
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<BlockNoteSideMenu editor={editor}>
|
|||
|
|
{/* 默认项 */}
|
|||
|
|
<button onClick={() => editor.duplicateBlock()}><HiOutlineDocumentDuplicate /> 复制</button>
|
|||
|
|
<button onClick={() => editor.removeBlock()}><HiOutlineTrash /> 删除</button>
|
|||
|
|
<button><HiOutlineColorSwatch /> 颜色</button>
|
|||
|
|
|
|||
|
|
{/* 自定义「转换为」子菜单 */}
|
|||
|
|
<div className="bn-menu-group">
|
|||
|
|
<div className="bn-menu-title">转换为</div>
|
|||
|
|
<button onClick={turnToPage} className="bn-menu-item flex items-center gap-2">
|
|||
|
|
<MdOutlineSubdirectoryArrowRight className="text-[#2563eb]" />
|
|||
|
|
页面
|
|||
|
|
</button>
|
|||
|
|
{/* 可继续加 标题1-6、清单、代码块 等 */}
|
|||
|
|
</div>
|
|||
|
|
</BlockNoteSideMenu>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
然后在 BlockNoteView 中替换:
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
<BlockNoteView editor={editor} sideMenu={false}> {/* 关闭默认 */}
|
|||
|
|
<CustomSideMenu editor={editor} currentDocumentId={documentId} />
|
|||
|
|
</BlockNoteView>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 5. 在 / 斜杠菜单也加上「页面」选项(双保险)
|
|||
|
|
|
|||
|
|
```tsx
|
|||
|
|
// src/components/editor/menus/CustomSlashMenu.tsx (类似上面)
|
|||
|
|
const pageSlashItem = {
|
|||
|
|
title: "页面",
|
|||
|
|
onItemClick: async () => {
|
|||
|
|
// 和 turnToPage 完全一样的逻辑,只是插入新块而不是替换
|
|||
|
|
const res = await fetch("/api/documents/create-child", { ... });
|
|||
|
|
const { pageId, title } = await res.json();
|
|||
|
|
editor.insertBlocks([{
|
|||
|
|
type: "pageReference",
|
|||
|
|
props: { pageId, title },
|
|||
|
|
}], editor.getTextCursorPosition().block, "after");
|
|||
|
|
},
|
|||
|
|
aliases: ["page", "子页面", "嵌入页面块"],
|
|||
|
|
group: "嵌入",
|
|||
|
|
icon: <RiFileTextFill />,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
editor.slashMenu.addItems([pageSlashItem]);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 6. 交给 AI 编码助手的完整 Prompt(直接复制)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
你现在接手一个刚完成 Stage1 的 Wolai 克隆项目(BlockNote + Supabase + 递归侧边栏 + 实时保存)。
|
|||
|
|
请精确克隆 Wolai「转为页面」功能:
|
|||
|
|
1. 新建 /api/documents/create-child/route.ts (POST)接收 parentId + title + blocks,创建新 document 并返回 pageId + title
|
|||
|
|
2. 创建自定义块 type="pageReference",渲染为蓝色带文件图标的可点击块,点击跳转 /documents/[pageId]
|
|||
|
|
3. 自定义 SideMenu(::: 柄菜单):在默认 Delete/Duplicate/Colors 下面加一个「转换为」分组,里面有「页面」选项,点击后调用 API 创建子页面并把当前块替换为 pageReference 块
|
|||
|
|
4. 同时在 SlashMenu(/ 菜单)添加「页面」项,执行相同创建+插入逻辑
|
|||
|
|
5. 所有样式严格遵循 ui_react.md:蓝色 #2563eb、圆角 4px、hover #f5f5f5、Inter 字体
|
|||
|
|
6. 确保侧边栏实时刷新(已有 recursive CTE + Realtime 订阅即可自动)
|
|||
|
|
请输出完整文件路径 + 完整代码,不要省略任何细节。
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
执行完以上步骤后,你的项目就拥有了 Wolai 最灵魂的「块 ↔ 页面」双向转换能力,父页面中会自动出现所有子页面块,侧边栏也完美缩进。
|
|||
|
|
|
|||
|
|
存为 `PHASE_1.5_TURN_TO_PAGE_EXACT_CLONE.md`
|
|||
|
|
```
|