Files
mnote/design/PHASE_1.5_TURN_TO_PAGE_EXACT_CLONE.md
T
2025-11-23 10:55:04 +08:00

8.1 KiB
Raw Blame History

# 阶段 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 蓝色样式)

// 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 中注册自定义块

// src/components/editor/schema.ts  (新建文件)
import { defaultBlockSchema } from "@blocknote/core";
import { pageReferenceBlock } from "./blocks/PageReferenceBlock";

export const customSchema = {
  ...defaultBlockSchema,
  pageReference: pageReferenceBlock,
};
// src/components/editor/BlockNoteEditor.tsx  (修改 useCreateBlockNote
import { customSchema } from "./schema";

const editor = useCreateBlockNote({
  initialContent: initialContent,
  schema: customSchema,   // ← 关键
});

4. 核心:自定义 SideMenu(::: 柄菜单)添加「转换为 → 页面」

BlockNote 官方支持完全自定义 SideMenu:

// 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 中替换:

<BlockNoteView editor={editor} sideMenu={false}>  {/* 关闭默认 */}
  <CustomSideMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>

5. 在 / 斜杠菜单也加上「页面」选项(双保险)

// 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