主编辑区改造准备

This commit is contained in:
lix-2026
2026-04-21 06:26:35 +08:00
parent 1e686bfa3c
commit 5d1c94eb9e
49 changed files with 6730 additions and 2565 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+52
View File
@@ -0,0 +1,52 @@
[
{
"content": "审计真实 documents.content/documents.save/Convex content contract",
"activeForm": "审计真实 documents.content/documents.save/Convex content contract",
"status": "completed"
},
{
"content": "把 LeptosTiptapEditorHost 从 query-string 初始化改成 bootstrap postMessage",
"activeForm": "把 LeptosTiptapEditorHost 从 query-string 初始化改成 bootstrap postMessage",
"status": "in_progress"
},
{
"content": "让 8123 runtime 支持 bootstrap/reset 并避免跨文档复用错误状态",
"activeForm": "让 8123 runtime 支持 bootstrap/reset 并避免跨文档复用错误状态",
"status": "in_progress"
},
{
"content": "补 iframe 高度同步",
"activeForm": "补 iframe 高度同步",
"status": "in_progress"
},
{
"content": "建立 stored content 到 tiptap doc 的正式 adapter",
"activeForm": "建立 stored content 到 tiptap doc 的正式 adapter",
"status": "pending"
},
{
"content": "建立 tiptap doc 到 stored content 的正式 adapter",
"activeForm": "建立 tiptap doc 到 stored content 的正式 adapter",
"status": "pending"
},
{
"content": "接通真实保存与 persisted meta 回写",
"activeForm": "接通真实保存与 persisted meta 回写",
"status": "pending"
},
{
"content": "固定 /documents/[id] 默认直接编辑态",
"activeForm": "固定 /documents/[id] 默认直接编辑态",
"status": "pending"
},
{
"content": "固定 EditorHost 主路径为 tiptap 并把 BlockNote 退到 compat",
"activeForm": "固定 EditorHost 主路径为 tiptap 并把 BlockNote 退到 compat",
"status": "pending"
},
{
"content": "补验收脚本并完成主编辑器人工验收准备",
"activeForm": "补验收脚本并完成主编辑器人工验收准备",
"status": "pending"
}
]
@@ -0,0 +1,5 @@
- generic [active] [ref=e1]:
- paragraph [ref=e5]: 加载中...
- button "Open Next.js Dev Tools" [ref=e11] [cursor=pointer]:
- img [ref=e12]
- alert [ref=e15]
@@ -0,0 +1,368 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]: 当前工作空间
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: 开发用户 的空间
- generic [ref=e11]: 个人空间
- button "切换(暂停)" [ref=e12]
- generic [ref=e13]: 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
- button "退出登录" [ref=e15]:
- img [ref=e16]
- generic [ref=e19]: 退出登录
- generic [ref=e20]:
- button "搜索" [ref=e21]:
- img [ref=e22]
- generic [ref=e25]: 搜索
- button "关系图" [ref=e26]:
- img [ref=e27]
- generic [ref=e33]: 关系图
- button "导入" [ref=e34]:
- img [ref=e35]
- generic [ref=e38]: 导入
- button "成员" [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: 成员
- button "星标置顶" [ref=e46]:
- img [ref=e47]
- generic [ref=e49]: 星标置顶
- button "公共页面" [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: 公共页面
- button "共享页面" [ref=e55]:
- img [ref=e56]
- generic [ref=e58]: 共享页面
- button "模板中心" [ref=e59]:
- img [ref=e60]
- generic [ref=e65]: 模板中心
- generic [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- img [ref=e70]
- text: 页面树
- generic [ref=e72]:
- textbox "搜索页面" [ref=e73]
- generic [ref=e74]:
- button "分组" [ref=e75]
- button "文件" [ref=e76]
- generic [ref=e77]:
- button "私有 / 我的页面" [ref=e78]:
- generic [ref=e79]: 私有 / 我的页面
- img [ref=e80]
- generic [ref=e88]:
- generic [ref=e90]:
- generic [ref=e92] [cursor=pointer]:
- button "拖拽排序" [ref=e93]:
- img [ref=e94]
- generic [ref=e101]:
- button "展开或折叠" [ref=e102]:
- img [ref=e103]
- link "新页面1" [ref=e105]:
- /url: /documents/00d00779-068a-4721-902a-136c8c4ada66
- button "新建子页面" [ref=e106]:
- img [ref=e107]
- button "更多操作" [ref=e108]:
- img [ref=e109]
- generic [ref=e114] [cursor=pointer]:
- button "拖拽排序" [ref=e115]:
- img [ref=e116]
- link "无标题2" [ref=e125]:
- /url: /documents/54698dd5-41f4-45f2-89dd-c217c94aa5ca
- button "新建子页面" [ref=e126]:
- img [ref=e127]
- button "更多操作" [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- button "拖拽排序" [ref=e135]:
- img [ref=e136]
- link "无标题3" [ref=e145]:
- /url: /documents/744e666b-1220-40f5-8760-f73b8529a2b3
- button "新建子页面" [ref=e146]:
- img [ref=e147]
- button "更多操作" [ref=e148]:
- img [ref=e149]
- generic [ref=e154] [cursor=pointer]:
- button "拖拽排序" [ref=e155]:
- img [ref=e156]
- generic [ref=e163]:
- button "展开或折叠" [ref=e164]:
- img [ref=e165]
- link "debug-created" [ref=e167]:
- /url: /documents/dac243b0-d9a2-42f8-a08c-53c36da6ac66
- button "新建子页面" [ref=e168]:
- img [ref=e169]
- button "更多操作" [ref=e170]:
- img [ref=e171]
- generic [ref=e176] [cursor=pointer]:
- button "拖拽排序" [ref=e177]:
- img [ref=e178]
- link "debug-child" [ref=e187]:
- /url: /documents/314f1473-2106-49ca-9755-094960d972c5
- button "新建子页面" [ref=e188]:
- img [ref=e189]
- button "更多操作" [ref=e190]:
- img [ref=e191]
- generic [ref=e196] [cursor=pointer]:
- button "拖拽排序" [ref=e197]:
- img [ref=e198]
- generic [ref=e205]:
- button "展开或折叠" [ref=e206]:
- img [ref=e207]
- link "inspect-root" [ref=e209]:
- /url: /documents/5fb627bd-04f0-4709-8455-7ecb3e298c87
- button "新建子页面" [ref=e210]:
- img [ref=e211]
- button "更多操作" [ref=e212]:
- img [ref=e213]
- generic [ref=e218] [cursor=pointer]:
- button "拖拽排序" [ref=e219]:
- img [ref=e220]
- link "inspect-child" [ref=e229]:
- /url: /documents/a596ff02-6da9-45c7-9e93-eda0451f98e3
- button "新建子页面" [ref=e230]:
- img [ref=e231]
- button "更多操作" [ref=e232]:
- img [ref=e233]
- generic [ref=e238] [cursor=pointer]:
- button "拖拽排序" [ref=e239]:
- img [ref=e240]
- generic [ref=e247]:
- button "展开或折叠" [ref=e248]:
- img [ref=e249]
- link "inspect-root-2" [ref=e251]:
- /url: /documents/a41b9474-dad9-419b-a442-d9e2054964b1
- button "新建子页面" [ref=e252]:
- img [ref=e253]
- button "更多操作" [ref=e254]:
- img [ref=e255]
- generic [ref=e260] [cursor=pointer]:
- button "拖拽排序" [ref=e261]:
- img [ref=e262]
- link "inspect-child-2" [ref=e271]:
- /url: /documents/a4b44b6e-a405-4528-91fe-2eeeaada015c
- button "新建子页面" [ref=e272]:
- img [ref=e273]
- button "更多操作" [ref=e274]:
- img [ref=e275]
- generic [ref=e280] [cursor=pointer]:
- button "拖拽排序" [ref=e281]:
- img [ref=e282]
- link "无标题" [ref=e291]:
- /url: /documents/tree_1776472443143_1
- button "新建子页面" [ref=e292]:
- img [ref=e293]
- button "更多操作" [ref=e294]:
- img [ref=e295]
- generic [ref=e300] [cursor=pointer]:
- button "拖拽排序" [ref=e301]:
- img [ref=e302]
- generic [ref=e309]:
- button "展开或折叠" [ref=e310]:
- img [ref=e311]
- link "task091-root-1776472545755" [ref=e313]:
- /url: /documents/66dc6880-2dd7-4836-b7a7-ef6342560fd1
- button "新建子页面" [ref=e314]:
- img [ref=e315]
- button "更多操作" [ref=e316]:
- img [ref=e317]
- generic [ref=e322] [cursor=pointer]:
- button "拖拽排序" [ref=e323]:
- img [ref=e324]
- link "task091-child-1776472545755" [ref=e333]:
- /url: /documents/0c59283a-6a41-48e9-b0a5-dd2893ee8e84
- button "新建子页面" [ref=e334]:
- img [ref=e335]
- button "更多操作" [ref=e336]:
- img [ref=e337]
- generic [ref=e342] [cursor=pointer]:
- button "拖拽排序" [ref=e343]:
- img [ref=e344]
- link "无标题" [ref=e353]:
- /url: /documents/tree_1776472546826_2
- button "新建子页面" [ref=e354]:
- img [ref=e355]
- button "更多操作" [ref=e356]:
- img [ref=e357]
- generic [ref=e362] [cursor=pointer]:
- button "拖拽排序" [ref=e363]:
- img [ref=e364]
- link "无标题" [ref=e373]:
- /url: /documents/59ed732f-9ea1-4da8-b6e6-7a48ccb1f013
- button "新建子页面" [ref=e374]:
- img [ref=e375]
- button "更多操作" [ref=e376]:
- img [ref=e377]
- generic [ref=e382] [cursor=pointer]:
- button "拖拽排序" [ref=e383]:
- img [ref=e384]
- link "无标题" [ref=e393]:
- /url: /documents/55560f41-a497-47f5-9606-9f6848bd710e
- button "新建子页面" [ref=e394]:
- img [ref=e395]
- button "更多操作" [ref=e396]:
- img [ref=e397]
- generic [ref=e402] [cursor=pointer]:
- button "拖拽排序" [ref=e403]:
- img [ref=e404]
- link "无标题" [ref=e413]:
- /url: /documents/1b9785e5-9a1d-401d-9790-7f01eb9ea6d0
- button "新建子页面" [ref=e414]:
- img [ref=e415]
- button "更多操作" [ref=e416]:
- img [ref=e417]
- generic [ref=e422] [cursor=pointer]:
- button "拖拽排序" [ref=e423]:
- img [ref=e424]
- link "无标题" [ref=e433]:
- /url: /documents/575cae40-ef3b-48c3-bcbe-26ba1cffb274
- button "新建子页面" [ref=e434]:
- img [ref=e435]
- button "更多操作" [ref=e436]:
- img [ref=e437]
- generic [ref=e442] [cursor=pointer]:
- button "拖拽排序" [ref=e443]:
- img [ref=e444]
- link "无标题" [ref=e453]:
- /url: /documents/d6029c24-3597-4152-b3a6-94de16142595
- button "新建子页面" [ref=e454]:
- img [ref=e455]
- button "更多操作" [ref=e456]:
- img [ref=e457]
- generic [ref=e462] [cursor=pointer]:
- button "拖拽排序" [ref=e463]:
- img [ref=e464]
- link "无标题" [ref=e473]:
- /url: /documents/2da9bf74-65bc-4c25-9888-6aee81d6ed79
- button "新建子页面" [ref=e474]:
- img [ref=e475]
- button "更多操作" [ref=e476]:
- img [ref=e477]
- generic [ref=e482] [cursor=pointer]:
- button "拖拽排序" [ref=e483]:
- img [ref=e484]
- link "无标题" [ref=e493]:
- /url: /documents/f1a44544-284e-4948-8795-05f2446d7cd3
- button "新建子页面" [ref=e494]:
- img [ref=e495]
- button "更多操作" [ref=e496]:
- img [ref=e497]
- generic [ref=e502] [cursor=pointer]:
- button "拖拽排序" [ref=e503]:
- img [ref=e504]
- link "无标题" [ref=e513]:
- /url: /documents/a33ab29b-540e-43c3-8413-841a3fb9dc30
- button "新建子页面" [ref=e514]:
- img [ref=e515]
- button "更多操作" [ref=e516]:
- img [ref=e517]
- generic [ref=e522] [cursor=pointer]:
- button "拖拽排序" [ref=e523]:
- img [ref=e524]
- link "无标题" [ref=e533]:
- /url: /documents/67ca9680-231d-46a9-baed-1c13244e0724
- button "新建子页面" [ref=e534]:
- img [ref=e535]
- button "更多操作" [ref=e536]:
- img [ref=e537]
- generic [ref=e542] [cursor=pointer]:
- button "拖拽排序" [ref=e543]:
- img [ref=e544]
- link "无标题" [ref=e553]:
- /url: /documents/952a6c2f-6676-477c-8b2d-8e7f8fe594c8
- button "新建子页面" [ref=e554]:
- img [ref=e555]
- button "更多操作" [ref=e556]:
- img [ref=e557]
- generic [ref=e562] [cursor=pointer]:
- button "拖拽排序" [ref=e563]:
- img [ref=e564]
- link "无标题" [ref=e573]:
- /url: /documents/8337ceec-e236-41d7-a2eb-86838f46c858
- button "新建子页面" [ref=e574]:
- img [ref=e575]
- button "更多操作" [ref=e576]:
- img [ref=e577]
- generic [ref=e582] [cursor=pointer]:
- button "拖拽排序" [ref=e583]:
- img [ref=e584]
- link "无标题" [ref=e593]:
- /url: /documents/7c690634-4638-43eb-8bb4-ccfa79b94373
- button "新建子页面" [ref=e594]:
- img [ref=e595]
- button "更多操作" [ref=e596]:
- img [ref=e597]
- generic [ref=e602] [cursor=pointer]:
- button "拖拽排序" [ref=e603]:
- img [ref=e604]
- link "无标题" [ref=e613]:
- /url: /documents/153fde24-7692-4a38-a6e4-9187b5255ad5
- button "新建子页面" [ref=e614]:
- img [ref=e615]
- button "更多操作" [ref=e616]:
- img [ref=e617]
- generic [ref=e622] [cursor=pointer]:
- button "拖拽排序" [ref=e623]:
- img [ref=e624]
- link "无标题" [ref=e633]:
- /url: /documents/9a8f8d18-7c6a-4647-ab3b-7ac131eee6f4
- button "新建子页面" [ref=e634]:
- img [ref=e635]
- button "更多操作" [ref=e636]:
- img [ref=e637]
- generic [ref=e642] [cursor=pointer]:
- button "拖拽排序" [ref=e643]:
- img [ref=e644]
- link "无标题" [ref=e653]:
- /url: /documents/5d47bc40-ea69-466d-b79d-76a71bc0f76a
- button "新建子页面" [ref=e654]:
- img [ref=e655]
- button "更多操作" [ref=e656]:
- img [ref=e657]
- generic [ref=e662] [cursor=pointer]:
- button "拖拽排序" [ref=e663]:
- img [ref=e664]
- link "无标题" [ref=e673]:
- /url: /documents/14d71939-56f3-4de7-99b6-592b543d426f
- button "新建子页面" [ref=e674]:
- img [ref=e675]
- button "更多操作" [ref=e676]:
- img [ref=e677]
- status [ref=e681]
- generic [ref=e682]:
- button "新建页面" [ref=e683]:
- img
- text: 新建页面
- button "垃圾桶 1 条" [ref=e684]:
- generic [ref=e685]:
- img [ref=e686]
- text: 垃圾桶
- generic [ref=e689]: 1
- generic [ref=e691]:
- banner [ref=e692]:
- generic [ref=e693]:
- navigation [ref=e694]:
- generic [ref=e696]: 新页面1
- generic [ref=e697]:
- generic "后端连接状态" [ref=e698]
- button "页面AI" [disabled] [ref=e699]:
- img [ref=e700]
- text: 页面AI
- button "收藏" [disabled] [ref=e703]:
- img [ref=e704]
- text: 收藏
- button "显示页面选项" [ref=e706]:
- img [ref=e707]
- main [ref=e711]:
- generic [ref=e715]:
- generic [ref=e718]:
- textbox "页面标题" [ref=e720]:
- /placeholder: 无标题
- text: 新页面1
- paragraph [ref=e721]: 最近更新:2026/4/20 22:02:26
- generic [ref=e725]: 编辑器加载中...
- button "Open Next.js Dev Tools" [ref=e731] [cursor=pointer]:
- img [ref=e732]
- alert [ref=e735]
@@ -0,0 +1,361 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]: 当前工作空间
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: 开发用户 的空间
- generic [ref=e11]: 个人空间
- button "切换(暂停)" [ref=e12]
- generic [ref=e13]: 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
- button "退出登录" [ref=e15]:
- img [ref=e16]
- generic [ref=e19]: 退出登录
- generic [ref=e20]:
- button "搜索" [ref=e21]:
- img [ref=e22]
- generic [ref=e25]: 搜索
- button "关系图" [ref=e26]:
- img [ref=e27]
- generic [ref=e33]: 关系图
- button "导入" [ref=e34]:
- img [ref=e35]
- generic [ref=e38]: 导入
- button "成员" [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: 成员
- button "星标置顶" [ref=e46]:
- img [ref=e47]
- generic [ref=e49]: 星标置顶
- button "公共页面" [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: 公共页面
- button "共享页面" [ref=e55]:
- img [ref=e56]
- generic [ref=e58]: 共享页面
- button "模板中心" [ref=e59]:
- img [ref=e60]
- generic [ref=e65]: 模板中心
- generic [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- img [ref=e70]
- text: 页面树
- generic [ref=e72]:
- textbox "搜索页面" [ref=e73]
- generic [ref=e74]:
- button "分组" [ref=e75]
- button "文件" [ref=e76]
- generic [ref=e77]:
- button "私有 / 我的页面" [ref=e78]:
- generic [ref=e79]: 私有 / 我的页面
- img [ref=e80]
- generic [ref=e88]:
- generic [ref=e90]:
- generic [ref=e92] [cursor=pointer]:
- button "拖拽排序" [ref=e93]:
- img [ref=e94]
- generic [ref=e101]:
- button "展开或折叠" [ref=e102]:
- img [ref=e103]
- link "新页面1" [ref=e105]:
- /url: /documents/00d00779-068a-4721-902a-136c8c4ada66
- button "新建子页面" [ref=e106]:
- img [ref=e107]
- button "更多操作" [ref=e108]:
- img [ref=e109]
- generic [ref=e114] [cursor=pointer]:
- button "拖拽排序" [ref=e115]:
- img [ref=e116]
- link "无标题2" [ref=e125]:
- /url: /documents/54698dd5-41f4-45f2-89dd-c217c94aa5ca
- button "新建子页面" [ref=e126]:
- img [ref=e127]
- button "更多操作" [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- button "拖拽排序" [ref=e135]:
- img [ref=e136]
- link "无标题3" [ref=e145]:
- /url: /documents/744e666b-1220-40f5-8760-f73b8529a2b3
- button "新建子页面" [ref=e146]:
- img [ref=e147]
- button "更多操作" [ref=e148]:
- img [ref=e149]
- generic [ref=e154] [cursor=pointer]:
- button "拖拽排序" [ref=e155]:
- img [ref=e156]
- generic [ref=e163]:
- button "展开或折叠" [ref=e164]:
- img [ref=e165]
- link "debug-created" [ref=e167]:
- /url: /documents/dac243b0-d9a2-42f8-a08c-53c36da6ac66
- button "新建子页面" [ref=e168]:
- img [ref=e169]
- button "更多操作" [ref=e170]:
- img [ref=e171]
- generic [ref=e176] [cursor=pointer]:
- button "拖拽排序" [ref=e177]:
- img [ref=e178]
- link "debug-child" [ref=e187]:
- /url: /documents/314f1473-2106-49ca-9755-094960d972c5
- button "新建子页面" [ref=e188]:
- img [ref=e189]
- button "更多操作" [ref=e190]:
- img [ref=e191]
- generic [ref=e196] [cursor=pointer]:
- button "拖拽排序" [ref=e197]:
- img [ref=e198]
- generic [ref=e205]:
- button "展开或折叠" [ref=e206]:
- img [ref=e207]
- link "inspect-root" [ref=e209]:
- /url: /documents/5fb627bd-04f0-4709-8455-7ecb3e298c87
- button "新建子页面" [ref=e210]:
- img [ref=e211]
- button "更多操作" [ref=e212]:
- img [ref=e213]
- generic [ref=e218] [cursor=pointer]:
- button "拖拽排序" [ref=e219]:
- img [ref=e220]
- link "inspect-child" [ref=e229]:
- /url: /documents/a596ff02-6da9-45c7-9e93-eda0451f98e3
- button "新建子页面" [ref=e230]:
- img [ref=e231]
- button "更多操作" [ref=e232]:
- img [ref=e233]
- generic [ref=e238] [cursor=pointer]:
- button "拖拽排序" [ref=e239]:
- img [ref=e240]
- generic [ref=e247]:
- button "展开或折叠" [ref=e248]:
- img [ref=e249]
- link "inspect-root-2" [ref=e251]:
- /url: /documents/a41b9474-dad9-419b-a442-d9e2054964b1
- button "新建子页面" [ref=e252]:
- img [ref=e253]
- button "更多操作" [ref=e254]:
- img [ref=e255]
- generic [ref=e260] [cursor=pointer]:
- button "拖拽排序" [ref=e261]:
- img [ref=e262]
- link "inspect-child-2" [ref=e271]:
- /url: /documents/a4b44b6e-a405-4528-91fe-2eeeaada015c
- button "新建子页面" [ref=e272]:
- img [ref=e273]
- button "更多操作" [ref=e274]:
- img [ref=e275]
- generic [ref=e280] [cursor=pointer]:
- button "拖拽排序" [ref=e281]:
- img [ref=e282]
- link "无标题" [ref=e291]:
- /url: /documents/tree_1776472443143_1
- button "新建子页面" [ref=e292]:
- img [ref=e293]
- button "更多操作" [ref=e294]:
- img [ref=e295]
- generic [ref=e300] [cursor=pointer]:
- button "拖拽排序" [ref=e301]:
- img [ref=e302]
- generic [ref=e309]:
- button "展开或折叠" [ref=e310]:
- img [ref=e311]
- link "task091-root-1776472545755" [ref=e313]:
- /url: /documents/66dc6880-2dd7-4836-b7a7-ef6342560fd1
- button "新建子页面" [ref=e314]:
- img [ref=e315]
- button "更多操作" [ref=e316]:
- img [ref=e317]
- generic [ref=e322] [cursor=pointer]:
- button "拖拽排序" [ref=e323]:
- img [ref=e324]
- link "task091-child-1776472545755" [ref=e333]:
- /url: /documents/0c59283a-6a41-48e9-b0a5-dd2893ee8e84
- button "新建子页面" [ref=e334]:
- img [ref=e335]
- button "更多操作" [ref=e336]:
- img [ref=e337]
- generic [ref=e342] [cursor=pointer]:
- button "拖拽排序" [ref=e343]:
- img [ref=e344]
- link "无标题" [ref=e353]:
- /url: /documents/tree_1776472546826_2
- button "新建子页面" [ref=e354]:
- img [ref=e355]
- button "更多操作" [ref=e356]:
- img [ref=e357]
- generic [ref=e362] [cursor=pointer]:
- button "拖拽排序" [ref=e363]:
- img [ref=e364]
- link "无标题" [ref=e373]:
- /url: /documents/59ed732f-9ea1-4da8-b6e6-7a48ccb1f013
- button "新建子页面" [ref=e374]:
- img [ref=e375]
- button "更多操作" [ref=e376]:
- img [ref=e377]
- generic [ref=e382] [cursor=pointer]:
- button "拖拽排序" [ref=e383]:
- img [ref=e384]
- link "无标题" [ref=e393]:
- /url: /documents/55560f41-a497-47f5-9606-9f6848bd710e
- button "新建子页面" [ref=e394]:
- img [ref=e395]
- button "更多操作" [ref=e396]:
- img [ref=e397]
- generic [ref=e402] [cursor=pointer]:
- button "拖拽排序" [ref=e403]:
- img [ref=e404]
- link "无标题" [ref=e413]:
- /url: /documents/1b9785e5-9a1d-401d-9790-7f01eb9ea6d0
- button "新建子页面" [ref=e414]:
- img [ref=e415]
- button "更多操作" [ref=e416]:
- img [ref=e417]
- generic [ref=e422] [cursor=pointer]:
- button "拖拽排序" [ref=e423]:
- img [ref=e424]
- link "无标题" [ref=e433]:
- /url: /documents/575cae40-ef3b-48c3-bcbe-26ba1cffb274
- button "新建子页面" [ref=e434]:
- img [ref=e435]
- button "更多操作" [ref=e436]:
- img [ref=e437]
- generic [ref=e442] [cursor=pointer]:
- button "拖拽排序" [ref=e443]:
- img [ref=e444]
- link "无标题" [ref=e453]:
- /url: /documents/d6029c24-3597-4152-b3a6-94de16142595
- button "新建子页面" [ref=e454]:
- img [ref=e455]
- button "更多操作" [ref=e456]:
- img [ref=e457]
- generic [ref=e462] [cursor=pointer]:
- button "拖拽排序" [ref=e463]:
- img [ref=e464]
- link "无标题" [ref=e473]:
- /url: /documents/2da9bf74-65bc-4c25-9888-6aee81d6ed79
- button "新建子页面" [ref=e474]:
- img [ref=e475]
- button "更多操作" [ref=e476]:
- img [ref=e477]
- generic [ref=e482] [cursor=pointer]:
- button "拖拽排序" [ref=e483]:
- img [ref=e484]
- link "无标题" [ref=e493]:
- /url: /documents/f1a44544-284e-4948-8795-05f2446d7cd3
- button "新建子页面" [ref=e494]:
- img [ref=e495]
- button "更多操作" [ref=e496]:
- img [ref=e497]
- generic [ref=e502] [cursor=pointer]:
- button "拖拽排序" [ref=e503]:
- img [ref=e504]
- link "无标题" [ref=e513]:
- /url: /documents/a33ab29b-540e-43c3-8413-841a3fb9dc30
- button "新建子页面" [ref=e514]:
- img [ref=e515]
- button "更多操作" [ref=e516]:
- img [ref=e517]
- generic [ref=e522] [cursor=pointer]:
- button "拖拽排序" [ref=e523]:
- img [ref=e524]
- link "无标题" [ref=e533]:
- /url: /documents/67ca9680-231d-46a9-baed-1c13244e0724
- button "新建子页面" [ref=e534]:
- img [ref=e535]
- button "更多操作" [ref=e536]:
- img [ref=e537]
- generic [ref=e542] [cursor=pointer]:
- button "拖拽排序" [ref=e543]:
- img [ref=e544]
- link "无标题" [ref=e553]:
- /url: /documents/952a6c2f-6676-477c-8b2d-8e7f8fe594c8
- button "新建子页面" [ref=e554]:
- img [ref=e555]
- button "更多操作" [ref=e556]:
- img [ref=e557]
- generic [ref=e562] [cursor=pointer]:
- button "拖拽排序" [ref=e563]:
- img [ref=e564]
- link "无标题" [ref=e573]:
- /url: /documents/8337ceec-e236-41d7-a2eb-86838f46c858
- button "新建子页面" [ref=e574]:
- img [ref=e575]
- button "更多操作" [ref=e576]:
- img [ref=e577]
- generic [ref=e582] [cursor=pointer]:
- button "拖拽排序" [ref=e583]:
- img [ref=e584]
- link "无标题" [ref=e593]:
- /url: /documents/7c690634-4638-43eb-8bb4-ccfa79b94373
- button "新建子页面" [ref=e594]:
- img [ref=e595]
- button "更多操作" [ref=e596]:
- img [ref=e597]
- generic [ref=e602] [cursor=pointer]:
- button "拖拽排序" [ref=e603]:
- img [ref=e604]
- link "无标题" [ref=e613]:
- /url: /documents/153fde24-7692-4a38-a6e4-9187b5255ad5
- button "新建子页面" [ref=e614]:
- img [ref=e615]
- button "更多操作" [ref=e616]:
- img [ref=e617]
- generic [ref=e622] [cursor=pointer]:
- button "拖拽排序" [ref=e623]:
- img [ref=e624]
- link "无标题" [ref=e633]:
- /url: /documents/9a8f8d18-7c6a-4647-ab3b-7ac131eee6f4
- button "新建子页面" [ref=e634]:
- img [ref=e635]
- button "更多操作" [ref=e636]:
- img [ref=e637]
- generic [ref=e642] [cursor=pointer]:
- button "拖拽排序" [ref=e643]:
- img [ref=e644]
- link "无标题" [ref=e653]:
- /url: /documents/5d47bc40-ea69-466d-b79d-76a71bc0f76a
- button "新建子页面" [ref=e654]:
- img [ref=e655]
- button "更多操作" [ref=e656]:
- img [ref=e657]
- generic [ref=e662] [cursor=pointer]:
- button "拖拽排序" [ref=e663]:
- img [ref=e664]
- link "无标题" [ref=e673]:
- /url: /documents/14d71939-56f3-4de7-99b6-592b543d426f
- button "新建子页面" [ref=e674]:
- img [ref=e675]
- button "更多操作" [ref=e676]:
- img [ref=e677]
- status [ref=e681]
- generic [ref=e682]:
- button "新建页面" [ref=e683]:
- img
- text: 新建页面
- button "垃圾桶 1 条" [ref=e684]:
- generic [ref=e685]:
- img [ref=e686]
- text: 垃圾桶
- generic [ref=e689]: 1
- generic [ref=e691]:
- banner [ref=e692]:
- generic [ref=e693]:
- navigation [ref=e694]:
- generic [ref=e696]: 新页面1
- generic [ref=e697]:
- generic "后端连接状态" [ref=e698]
- button "页面AI" [disabled] [ref=e699]:
- img [ref=e700]
- text: 页面AI
- button "收藏" [disabled] [ref=e703]:
- img [ref=e704]
- text: 收藏
- button "显示页面选项" [ref=e706]:
- img [ref=e707]
- main [ref=e711]
- button "Open Next.js Dev Tools" [ref=e721] [cursor=pointer]:
- img [ref=e722]
- alert [ref=e725]
@@ -0,0 +1,368 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]: 当前工作空间
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: 开发用户 的空间
- generic [ref=e11]: 个人空间
- button "切换(暂停)" [ref=e12]
- generic [ref=e13]: 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
- button "退出登录" [ref=e15]:
- img [ref=e16]
- generic [ref=e19]: 退出登录
- generic [ref=e20]:
- button "搜索" [ref=e21]:
- img [ref=e22]
- generic [ref=e25]: 搜索
- button "关系图" [ref=e26]:
- img [ref=e27]
- generic [ref=e33]: 关系图
- button "导入" [ref=e34]:
- img [ref=e35]
- generic [ref=e38]: 导入
- button "成员" [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: 成员
- button "星标置顶" [ref=e46]:
- img [ref=e47]
- generic [ref=e49]: 星标置顶
- button "公共页面" [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: 公共页面
- button "共享页面" [ref=e55]:
- img [ref=e56]
- generic [ref=e58]: 共享页面
- button "模板中心" [ref=e59]:
- img [ref=e60]
- generic [ref=e65]: 模板中心
- generic [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- img [ref=e70]
- text: 页面树
- generic [ref=e72]:
- textbox "搜索页面" [ref=e73]
- generic [ref=e74]:
- button "分组" [ref=e75]
- button "文件" [ref=e76]
- generic [ref=e77]:
- button "私有 / 我的页面" [ref=e78]:
- generic [ref=e79]: 私有 / 我的页面
- img [ref=e80]
- generic [ref=e88]:
- generic [ref=e90]:
- generic [ref=e92] [cursor=pointer]:
- button "拖拽排序" [ref=e93]:
- img [ref=e94]
- generic [ref=e101]:
- button "展开或折叠" [ref=e102]:
- img [ref=e103]
- link "新页面1" [ref=e105]:
- /url: /documents/00d00779-068a-4721-902a-136c8c4ada66
- button "新建子页面" [ref=e106]:
- img [ref=e107]
- button "更多操作" [ref=e108]:
- img [ref=e109]
- generic [ref=e114] [cursor=pointer]:
- button "拖拽排序" [ref=e115]:
- img [ref=e116]
- link "无标题2" [ref=e125]:
- /url: /documents/54698dd5-41f4-45f2-89dd-c217c94aa5ca
- button "新建子页面" [ref=e126]:
- img [ref=e127]
- button "更多操作" [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- button "拖拽排序" [ref=e135]:
- img [ref=e136]
- link "无标题3" [ref=e145]:
- /url: /documents/744e666b-1220-40f5-8760-f73b8529a2b3
- button "新建子页面" [ref=e146]:
- img [ref=e147]
- button "更多操作" [ref=e148]:
- img [ref=e149]
- generic [ref=e154] [cursor=pointer]:
- button "拖拽排序" [ref=e155]:
- img [ref=e156]
- generic [ref=e163]:
- button "展开或折叠" [ref=e164]:
- img [ref=e165]
- link "debug-created" [ref=e167]:
- /url: /documents/dac243b0-d9a2-42f8-a08c-53c36da6ac66
- button "新建子页面" [ref=e168]:
- img [ref=e169]
- button "更多操作" [ref=e170]:
- img [ref=e171]
- generic [ref=e176] [cursor=pointer]:
- button "拖拽排序" [ref=e177]:
- img [ref=e178]
- link "debug-child" [ref=e187]:
- /url: /documents/314f1473-2106-49ca-9755-094960d972c5
- button "新建子页面" [ref=e188]:
- img [ref=e189]
- button "更多操作" [ref=e190]:
- img [ref=e191]
- generic [ref=e196] [cursor=pointer]:
- button "拖拽排序" [ref=e197]:
- img [ref=e198]
- generic [ref=e205]:
- button "展开或折叠" [ref=e206]:
- img [ref=e207]
- link "inspect-root" [ref=e209]:
- /url: /documents/5fb627bd-04f0-4709-8455-7ecb3e298c87
- button "新建子页面" [ref=e210]:
- img [ref=e211]
- button "更多操作" [ref=e212]:
- img [ref=e213]
- generic [ref=e218] [cursor=pointer]:
- button "拖拽排序" [ref=e219]:
- img [ref=e220]
- link "inspect-child" [ref=e229]:
- /url: /documents/a596ff02-6da9-45c7-9e93-eda0451f98e3
- button "新建子页面" [ref=e230]:
- img [ref=e231]
- button "更多操作" [ref=e232]:
- img [ref=e233]
- generic [ref=e238] [cursor=pointer]:
- button "拖拽排序" [ref=e239]:
- img [ref=e240]
- generic [ref=e247]:
- button "展开或折叠" [ref=e248]:
- img [ref=e249]
- link "inspect-root-2" [ref=e251]:
- /url: /documents/a41b9474-dad9-419b-a442-d9e2054964b1
- button "新建子页面" [ref=e252]:
- img [ref=e253]
- button "更多操作" [ref=e254]:
- img [ref=e255]
- generic [ref=e260] [cursor=pointer]:
- button "拖拽排序" [ref=e261]:
- img [ref=e262]
- link "inspect-child-2" [ref=e271]:
- /url: /documents/a4b44b6e-a405-4528-91fe-2eeeaada015c
- button "新建子页面" [ref=e272]:
- img [ref=e273]
- button "更多操作" [ref=e274]:
- img [ref=e275]
- generic [ref=e280] [cursor=pointer]:
- button "拖拽排序" [ref=e281]:
- img [ref=e282]
- link "无标题" [ref=e291]:
- /url: /documents/tree_1776472443143_1
- button "新建子页面" [ref=e292]:
- img [ref=e293]
- button "更多操作" [ref=e294]:
- img [ref=e295]
- generic [ref=e300] [cursor=pointer]:
- button "拖拽排序" [ref=e301]:
- img [ref=e302]
- generic [ref=e309]:
- button "展开或折叠" [ref=e310]:
- img [ref=e311]
- link "task091-root-1776472545755" [ref=e313]:
- /url: /documents/66dc6880-2dd7-4836-b7a7-ef6342560fd1
- button "新建子页面" [ref=e314]:
- img [ref=e315]
- button "更多操作" [ref=e316]:
- img [ref=e317]
- generic [ref=e322] [cursor=pointer]:
- button "拖拽排序" [ref=e323]:
- img [ref=e324]
- link "task091-child-1776472545755" [ref=e333]:
- /url: /documents/0c59283a-6a41-48e9-b0a5-dd2893ee8e84
- button "新建子页面" [ref=e334]:
- img [ref=e335]
- button "更多操作" [ref=e336]:
- img [ref=e337]
- generic [ref=e342] [cursor=pointer]:
- button "拖拽排序" [ref=e343]:
- img [ref=e344]
- link "无标题" [ref=e353]:
- /url: /documents/tree_1776472546826_2
- button "新建子页面" [ref=e354]:
- img [ref=e355]
- button "更多操作" [ref=e356]:
- img [ref=e357]
- generic [ref=e362] [cursor=pointer]:
- button "拖拽排序" [ref=e363]:
- img [ref=e364]
- link "无标题" [ref=e373]:
- /url: /documents/59ed732f-9ea1-4da8-b6e6-7a48ccb1f013
- button "新建子页面" [ref=e374]:
- img [ref=e375]
- button "更多操作" [ref=e376]:
- img [ref=e377]
- generic [ref=e382] [cursor=pointer]:
- button "拖拽排序" [ref=e383]:
- img [ref=e384]
- link "无标题" [ref=e393]:
- /url: /documents/55560f41-a497-47f5-9606-9f6848bd710e
- button "新建子页面" [ref=e394]:
- img [ref=e395]
- button "更多操作" [ref=e396]:
- img [ref=e397]
- generic [ref=e402] [cursor=pointer]:
- button "拖拽排序" [ref=e403]:
- img [ref=e404]
- link "无标题" [ref=e413]:
- /url: /documents/1b9785e5-9a1d-401d-9790-7f01eb9ea6d0
- button "新建子页面" [ref=e414]:
- img [ref=e415]
- button "更多操作" [ref=e416]:
- img [ref=e417]
- generic [ref=e422] [cursor=pointer]:
- button "拖拽排序" [ref=e423]:
- img [ref=e424]
- link "无标题" [ref=e433]:
- /url: /documents/575cae40-ef3b-48c3-bcbe-26ba1cffb274
- button "新建子页面" [ref=e434]:
- img [ref=e435]
- button "更多操作" [ref=e436]:
- img [ref=e437]
- generic [ref=e442] [cursor=pointer]:
- button "拖拽排序" [ref=e443]:
- img [ref=e444]
- link "无标题" [ref=e453]:
- /url: /documents/d6029c24-3597-4152-b3a6-94de16142595
- button "新建子页面" [ref=e454]:
- img [ref=e455]
- button "更多操作" [ref=e456]:
- img [ref=e457]
- generic [ref=e462] [cursor=pointer]:
- button "拖拽排序" [ref=e463]:
- img [ref=e464]
- link "无标题" [ref=e473]:
- /url: /documents/2da9bf74-65bc-4c25-9888-6aee81d6ed79
- button "新建子页面" [ref=e474]:
- img [ref=e475]
- button "更多操作" [ref=e476]:
- img [ref=e477]
- generic [ref=e482] [cursor=pointer]:
- button "拖拽排序" [ref=e483]:
- img [ref=e484]
- link "无标题" [ref=e493]:
- /url: /documents/f1a44544-284e-4948-8795-05f2446d7cd3
- button "新建子页面" [ref=e494]:
- img [ref=e495]
- button "更多操作" [ref=e496]:
- img [ref=e497]
- generic [ref=e502] [cursor=pointer]:
- button "拖拽排序" [ref=e503]:
- img [ref=e504]
- link "无标题" [ref=e513]:
- /url: /documents/a33ab29b-540e-43c3-8413-841a3fb9dc30
- button "新建子页面" [ref=e514]:
- img [ref=e515]
- button "更多操作" [ref=e516]:
- img [ref=e517]
- generic [ref=e522] [cursor=pointer]:
- button "拖拽排序" [ref=e523]:
- img [ref=e524]
- link "无标题" [ref=e533]:
- /url: /documents/67ca9680-231d-46a9-baed-1c13244e0724
- button "新建子页面" [ref=e534]:
- img [ref=e535]
- button "更多操作" [ref=e536]:
- img [ref=e537]
- generic [ref=e542] [cursor=pointer]:
- button "拖拽排序" [ref=e543]:
- img [ref=e544]
- link "无标题" [ref=e553]:
- /url: /documents/952a6c2f-6676-477c-8b2d-8e7f8fe594c8
- button "新建子页面" [ref=e554]:
- img [ref=e555]
- button "更多操作" [ref=e556]:
- img [ref=e557]
- generic [ref=e562] [cursor=pointer]:
- button "拖拽排序" [ref=e563]:
- img [ref=e564]
- link "无标题" [ref=e573]:
- /url: /documents/8337ceec-e236-41d7-a2eb-86838f46c858
- button "新建子页面" [ref=e574]:
- img [ref=e575]
- button "更多操作" [ref=e576]:
- img [ref=e577]
- generic [ref=e582] [cursor=pointer]:
- button "拖拽排序" [ref=e583]:
- img [ref=e584]
- link "无标题" [ref=e593]:
- /url: /documents/7c690634-4638-43eb-8bb4-ccfa79b94373
- button "新建子页面" [ref=e594]:
- img [ref=e595]
- button "更多操作" [ref=e596]:
- img [ref=e597]
- generic [ref=e602] [cursor=pointer]:
- button "拖拽排序" [ref=e603]:
- img [ref=e604]
- link "无标题" [ref=e613]:
- /url: /documents/153fde24-7692-4a38-a6e4-9187b5255ad5
- button "新建子页面" [ref=e614]:
- img [ref=e615]
- button "更多操作" [ref=e616]:
- img [ref=e617]
- generic [ref=e622] [cursor=pointer]:
- button "拖拽排序" [ref=e623]:
- img [ref=e624]
- link "无标题" [ref=e633]:
- /url: /documents/9a8f8d18-7c6a-4647-ab3b-7ac131eee6f4
- button "新建子页面" [ref=e634]:
- img [ref=e635]
- button "更多操作" [ref=e636]:
- img [ref=e637]
- generic [ref=e642] [cursor=pointer]:
- button "拖拽排序" [ref=e643]:
- img [ref=e644]
- link "无标题" [ref=e653]:
- /url: /documents/5d47bc40-ea69-466d-b79d-76a71bc0f76a
- button "新建子页面" [ref=e654]:
- img [ref=e655]
- button "更多操作" [ref=e656]:
- img [ref=e657]
- generic [ref=e662] [cursor=pointer]:
- button "拖拽排序" [ref=e663]:
- img [ref=e664]
- link "无标题" [ref=e673]:
- /url: /documents/14d71939-56f3-4de7-99b6-592b543d426f
- button "新建子页面" [ref=e674]:
- img [ref=e675]
- button "更多操作" [ref=e676]:
- img [ref=e677]
- status [ref=e681]
- generic [ref=e682]:
- button "新建页面" [ref=e683]:
- img
- text: 新建页面
- button "垃圾桶 1 条" [ref=e684]:
- generic [ref=e685]:
- img [ref=e686]
- text: 垃圾桶
- generic [ref=e689]: 1
- generic [ref=e691]:
- banner [ref=e692]:
- generic [ref=e693]:
- navigation [ref=e694]:
- generic [ref=e696]: 新页面1
- generic [ref=e697]:
- generic "后端连接状态" [ref=e698]
- button "页面AI" [disabled] [ref=e699]:
- img [ref=e700]
- text: 页面AI
- button "收藏" [disabled] [ref=e703]:
- img [ref=e704]
- text: 收藏
- button "显示页面选项" [ref=e706]:
- img [ref=e707]
- main [ref=e711]:
- generic [ref=e715]:
- generic [ref=e718]:
- textbox "页面标题" [ref=e720]:
- /placeholder: 无标题
- text: 新页面1
- paragraph [ref=e721]: 最近更新:2026/4/20 22:23:52
- generic [ref=e725]: 编辑器加载中...
- button "Open Next.js Dev Tools" [ref=e731] [cursor=pointer]:
- img [ref=e732]
- alert [ref=e735]
@@ -0,0 +1,388 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]: 当前工作空间
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: 开发用户 的空间
- generic [ref=e11]: 个人空间
- button "切换(暂停)" [ref=e12]
- generic [ref=e13]: 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
- button "退出登录" [ref=e15]:
- img [ref=e16]
- generic [ref=e19]: 退出登录
- generic [ref=e20]:
- button "搜索" [ref=e21]:
- img [ref=e22]
- generic [ref=e25]: 搜索
- button "关系图" [ref=e26]:
- img [ref=e27]
- generic [ref=e33]: 关系图
- button "导入" [ref=e34]:
- img [ref=e35]
- generic [ref=e38]: 导入
- button "成员" [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: 成员
- button "星标置顶" [ref=e46]:
- img [ref=e47]
- generic [ref=e49]: 星标置顶
- button "公共页面" [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: 公共页面
- button "共享页面" [ref=e55]:
- img [ref=e56]
- generic [ref=e58]: 共享页面
- button "模板中心" [ref=e59]:
- img [ref=e60]
- generic [ref=e65]: 模板中心
- generic [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- img [ref=e70]
- text: 页面树
- generic [ref=e72]:
- textbox "搜索页面" [ref=e73]
- generic [ref=e74]:
- button "分组" [ref=e75]
- button "文件" [ref=e76]
- generic [ref=e77]:
- button "私有 / 我的页面" [ref=e78]:
- generic [ref=e79]: 私有 / 我的页面
- img [ref=e80]
- generic [ref=e88]:
- generic [ref=e90]:
- generic [ref=e92] [cursor=pointer]:
- button "拖拽排序" [ref=e93]:
- img [ref=e94]
- generic [ref=e101]:
- button "展开或折叠" [ref=e102]:
- img [ref=e103]
- link "新页面1" [ref=e105]:
- /url: /documents/00d00779-068a-4721-902a-136c8c4ada66
- button "新建子页面" [ref=e106]:
- img [ref=e107]
- button "更多操作" [ref=e108]:
- img [ref=e109]
- generic [ref=e114] [cursor=pointer]:
- button "拖拽排序" [ref=e115]:
- img [ref=e116]
- link "无标题2" [ref=e125]:
- /url: /documents/54698dd5-41f4-45f2-89dd-c217c94aa5ca
- button "新建子页面" [ref=e126]:
- img [ref=e127]
- button "更多操作" [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- button "拖拽排序" [ref=e135]:
- img [ref=e136]
- link "无标题3" [ref=e145]:
- /url: /documents/744e666b-1220-40f5-8760-f73b8529a2b3
- button "新建子页面" [ref=e146]:
- img [ref=e147]
- button "更多操作" [ref=e148]:
- img [ref=e149]
- generic [ref=e154] [cursor=pointer]:
- button "拖拽排序" [ref=e155]:
- img [ref=e156]
- generic [ref=e163]:
- button "展开或折叠" [ref=e164]:
- img [ref=e165]
- link "debug-created" [ref=e167]:
- /url: /documents/dac243b0-d9a2-42f8-a08c-53c36da6ac66
- button "新建子页面" [ref=e168]:
- img [ref=e169]
- button "更多操作" [ref=e170]:
- img [ref=e171]
- generic [ref=e176] [cursor=pointer]:
- button "拖拽排序" [ref=e177]:
- img [ref=e178]
- link "debug-child" [ref=e187]:
- /url: /documents/314f1473-2106-49ca-9755-094960d972c5
- button "新建子页面" [ref=e188]:
- img [ref=e189]
- button "更多操作" [ref=e190]:
- img [ref=e191]
- generic [ref=e196] [cursor=pointer]:
- button "拖拽排序" [ref=e197]:
- img [ref=e198]
- generic [ref=e205]:
- button "展开或折叠" [ref=e206]:
- img [ref=e207]
- link "inspect-root" [ref=e209]:
- /url: /documents/5fb627bd-04f0-4709-8455-7ecb3e298c87
- button "新建子页面" [ref=e210]:
- img [ref=e211]
- button "更多操作" [ref=e212]:
- img [ref=e213]
- generic [ref=e218] [cursor=pointer]:
- button "拖拽排序" [ref=e219]:
- img [ref=e220]
- link "inspect-child" [ref=e229]:
- /url: /documents/a596ff02-6da9-45c7-9e93-eda0451f98e3
- button "新建子页面" [ref=e230]:
- img [ref=e231]
- button "更多操作" [ref=e232]:
- img [ref=e233]
- generic [ref=e238] [cursor=pointer]:
- button "拖拽排序" [ref=e239]:
- img [ref=e240]
- generic [ref=e247]:
- button "展开或折叠" [ref=e248]:
- img [ref=e249]
- link "inspect-root-2" [ref=e251]:
- /url: /documents/a41b9474-dad9-419b-a442-d9e2054964b1
- button "新建子页面" [ref=e252]:
- img [ref=e253]
- button "更多操作" [ref=e254]:
- img [ref=e255]
- generic [ref=e260] [cursor=pointer]:
- button "拖拽排序" [ref=e261]:
- img [ref=e262]
- link "inspect-child-2" [ref=e271]:
- /url: /documents/a4b44b6e-a405-4528-91fe-2eeeaada015c
- button "新建子页面" [ref=e272]:
- img [ref=e273]
- button "更多操作" [ref=e274]:
- img [ref=e275]
- generic [ref=e280] [cursor=pointer]:
- button "拖拽排序" [ref=e281]:
- img [ref=e282]
- link "无标题" [ref=e291]:
- /url: /documents/tree_1776472443143_1
- button "新建子页面" [ref=e292]:
- img [ref=e293]
- button "更多操作" [ref=e294]:
- img [ref=e295]
- generic [ref=e300] [cursor=pointer]:
- button "拖拽排序" [ref=e301]:
- img [ref=e302]
- generic [ref=e309]:
- button "展开或折叠" [ref=e310]:
- img [ref=e311]
- link "task091-root-1776472545755" [ref=e313]:
- /url: /documents/66dc6880-2dd7-4836-b7a7-ef6342560fd1
- button "新建子页面" [ref=e314]:
- img [ref=e315]
- button "更多操作" [ref=e316]:
- img [ref=e317]
- generic [ref=e322] [cursor=pointer]:
- button "拖拽排序" [ref=e323]:
- img [ref=e324]
- link "task091-child-1776472545755" [ref=e333]:
- /url: /documents/0c59283a-6a41-48e9-b0a5-dd2893ee8e84
- button "新建子页面" [ref=e334]:
- img [ref=e335]
- button "更多操作" [ref=e336]:
- img [ref=e337]
- generic [ref=e342] [cursor=pointer]:
- button "拖拽排序" [ref=e343]:
- img [ref=e344]
- link "无标题" [ref=e353]:
- /url: /documents/tree_1776472546826_2
- button "新建子页面" [ref=e354]:
- img [ref=e355]
- button "更多操作" [ref=e356]:
- img [ref=e357]
- generic [ref=e362] [cursor=pointer]:
- button "拖拽排序" [ref=e363]:
- img [ref=e364]
- link "无标题" [ref=e373]:
- /url: /documents/59ed732f-9ea1-4da8-b6e6-7a48ccb1f013
- button "新建子页面" [ref=e374]:
- img [ref=e375]
- button "更多操作" [ref=e376]:
- img [ref=e377]
- generic [ref=e382] [cursor=pointer]:
- button "拖拽排序" [ref=e383]:
- img [ref=e384]
- link "无标题" [ref=e393]:
- /url: /documents/55560f41-a497-47f5-9606-9f6848bd710e
- button "新建子页面" [ref=e394]:
- img [ref=e395]
- button "更多操作" [ref=e396]:
- img [ref=e397]
- generic [ref=e402] [cursor=pointer]:
- button "拖拽排序" [ref=e403]:
- img [ref=e404]
- link "无标题" [ref=e413]:
- /url: /documents/1b9785e5-9a1d-401d-9790-7f01eb9ea6d0
- button "新建子页面" [ref=e414]:
- img [ref=e415]
- button "更多操作" [ref=e416]:
- img [ref=e417]
- generic [ref=e422] [cursor=pointer]:
- button "拖拽排序" [ref=e423]:
- img [ref=e424]
- link "无标题" [ref=e433]:
- /url: /documents/575cae40-ef3b-48c3-bcbe-26ba1cffb274
- button "新建子页面" [ref=e434]:
- img [ref=e435]
- button "更多操作" [ref=e436]:
- img [ref=e437]
- generic [ref=e442] [cursor=pointer]:
- button "拖拽排序" [ref=e443]:
- img [ref=e444]
- link "无标题" [ref=e453]:
- /url: /documents/d6029c24-3597-4152-b3a6-94de16142595
- button "新建子页面" [ref=e454]:
- img [ref=e455]
- button "更多操作" [ref=e456]:
- img [ref=e457]
- generic [ref=e462] [cursor=pointer]:
- button "拖拽排序" [ref=e463]:
- img [ref=e464]
- link "无标题" [ref=e473]:
- /url: /documents/2da9bf74-65bc-4c25-9888-6aee81d6ed79
- button "新建子页面" [ref=e474]:
- img [ref=e475]
- button "更多操作" [ref=e476]:
- img [ref=e477]
- generic [ref=e482] [cursor=pointer]:
- button "拖拽排序" [ref=e483]:
- img [ref=e484]
- link "无标题" [ref=e493]:
- /url: /documents/f1a44544-284e-4948-8795-05f2446d7cd3
- button "新建子页面" [ref=e494]:
- img [ref=e495]
- button "更多操作" [ref=e496]:
- img [ref=e497]
- generic [ref=e502] [cursor=pointer]:
- button "拖拽排序" [ref=e503]:
- img [ref=e504]
- link "无标题" [ref=e513]:
- /url: /documents/a33ab29b-540e-43c3-8413-841a3fb9dc30
- button "新建子页面" [ref=e514]:
- img [ref=e515]
- button "更多操作" [ref=e516]:
- img [ref=e517]
- generic [ref=e522] [cursor=pointer]:
- button "拖拽排序" [ref=e523]:
- img [ref=e524]
- link "无标题" [ref=e533]:
- /url: /documents/67ca9680-231d-46a9-baed-1c13244e0724
- button "新建子页面" [ref=e534]:
- img [ref=e535]
- button "更多操作" [ref=e536]:
- img [ref=e537]
- generic [ref=e542] [cursor=pointer]:
- button "拖拽排序" [ref=e543]:
- img [ref=e544]
- link "无标题" [ref=e553]:
- /url: /documents/952a6c2f-6676-477c-8b2d-8e7f8fe594c8
- button "新建子页面" [ref=e554]:
- img [ref=e555]
- button "更多操作" [ref=e556]:
- img [ref=e557]
- generic [ref=e562] [cursor=pointer]:
- button "拖拽排序" [ref=e563]:
- img [ref=e564]
- link "无标题" [ref=e573]:
- /url: /documents/8337ceec-e236-41d7-a2eb-86838f46c858
- button "新建子页面" [ref=e574]:
- img [ref=e575]
- button "更多操作" [ref=e576]:
- img [ref=e577]
- generic [ref=e582] [cursor=pointer]:
- button "拖拽排序" [ref=e583]:
- img [ref=e584]
- link "无标题" [ref=e593]:
- /url: /documents/7c690634-4638-43eb-8bb4-ccfa79b94373
- button "新建子页面" [ref=e594]:
- img [ref=e595]
- button "更多操作" [ref=e596]:
- img [ref=e597]
- generic [ref=e602] [cursor=pointer]:
- button "拖拽排序" [ref=e603]:
- img [ref=e604]
- link "无标题" [ref=e613]:
- /url: /documents/153fde24-7692-4a38-a6e4-9187b5255ad5
- button "新建子页面" [ref=e614]:
- img [ref=e615]
- button "更多操作" [ref=e616]:
- img [ref=e617]
- generic [ref=e622] [cursor=pointer]:
- button "拖拽排序" [ref=e623]:
- img [ref=e624]
- link "无标题" [ref=e633]:
- /url: /documents/9a8f8d18-7c6a-4647-ab3b-7ac131eee6f4
- button "新建子页面" [ref=e634]:
- img [ref=e635]
- button "更多操作" [ref=e636]:
- img [ref=e637]
- generic [ref=e642] [cursor=pointer]:
- button "拖拽排序" [ref=e643]:
- img [ref=e644]
- link "无标题" [ref=e653]:
- /url: /documents/5d47bc40-ea69-466d-b79d-76a71bc0f76a
- button "新建子页面" [ref=e654]:
- img [ref=e655]
- button "更多操作" [ref=e656]:
- img [ref=e657]
- generic [ref=e662] [cursor=pointer]:
- button "拖拽排序" [ref=e663]:
- img [ref=e664]
- link "无标题" [ref=e673]:
- /url: /documents/14d71939-56f3-4de7-99b6-592b543d426f
- button "新建子页面" [ref=e674]:
- img [ref=e675]
- button "更多操作" [ref=e676]:
- img [ref=e677]
- status [ref=e681]
- generic [ref=e682]:
- button "新建页面" [ref=e683]:
- img
- text: 新建页面
- button "垃圾桶 1 条" [ref=e684]:
- generic [ref=e685]:
- img [ref=e686]
- text: 垃圾桶
- generic [ref=e689]: 1
- generic [ref=e691]:
- banner [ref=e692]:
- generic [ref=e693]:
- navigation [ref=e694]:
- generic [ref=e696]: 新页面1
- generic [ref=e697]:
- generic "后端连接状态" [ref=e698]
- button "页面AI" [ref=e699]:
- img [ref=e700]
- text: 页面AI
- button "收藏" [ref=e703]:
- img [ref=e704]
- text: 收藏
- button "显示页面选项" [ref=e706]:
- img [ref=e707]
- main [ref=e711]:
- generic [ref=e715]:
- generic [ref=e718]:
- textbox "页面标题" [ref=e720]:
- /placeholder: 无标题
- text: 新页面1
- paragraph [ref=e721]: 最近更新:2026/4/20 22:23:52
- generic [ref=e736]:
- generic [ref=e737]:
- generic [ref=e738]: "实验主链:同页 `leptos-tiptap inline island`"
- generic [ref=e739]: ready
- textbox [ref=e774]:
- paragraph [ref=e775]: ssss
- paragraph [ref=e776]: ssss
- paragraph [ref=e777]: 三生三世三生三世
- paragraph [ref=e778]: /
- paragraph [ref=e779]
- paragraph [ref=e780]
- paragraph [ref=e781]
- generic [ref=e730] [cursor=pointer]:
- button "Open Next.js Dev Tools" [ref=e731]:
- img [ref=e732]
- generic [ref=e749]:
- button "Open issues overlay" [ref=e750]:
- generic [ref=e751]:
- generic [ref=e752]: "0"
- generic [ref=e753]: "1"
- generic [ref=e754]: Issue
- button "Collapse issues badge" [ref=e755]:
- img [ref=e756]
- alert [ref=e735]
@@ -0,0 +1,5 @@
- generic [active] [ref=e1]:
- paragraph [ref=e5]: 加载中...
- button "Open Next.js Dev Tools" [ref=e11] [cursor=pointer]:
- img [ref=e12]
- alert [ref=e15]
@@ -0,0 +1,368 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]: 当前工作空间
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: 开发用户 的空间
- generic [ref=e11]: 个人空间
- button "切换(暂停)" [ref=e12]
- generic [ref=e13]: 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
- button "退出登录" [ref=e15]:
- img [ref=e16]
- generic [ref=e19]: 退出登录
- generic [ref=e20]:
- button "搜索" [ref=e21]:
- img [ref=e22]
- generic [ref=e25]: 搜索
- button "关系图" [ref=e26]:
- img [ref=e27]
- generic [ref=e33]: 关系图
- button "导入" [ref=e34]:
- img [ref=e35]
- generic [ref=e38]: 导入
- button "成员" [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: 成员
- button "星标置顶" [ref=e46]:
- img [ref=e47]
- generic [ref=e49]: 星标置顶
- button "公共页面" [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: 公共页面
- button "共享页面" [ref=e55]:
- img [ref=e56]
- generic [ref=e58]: 共享页面
- button "模板中心" [ref=e59]:
- img [ref=e60]
- generic [ref=e65]: 模板中心
- generic [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- img [ref=e70]
- text: 页面树
- generic [ref=e72]:
- textbox "搜索页面" [ref=e73]
- generic [ref=e74]:
- button "分组" [ref=e75]
- button "文件" [ref=e76]
- generic [ref=e77]:
- button "私有 / 我的页面" [ref=e78]:
- generic [ref=e79]: 私有 / 我的页面
- img [ref=e80]
- generic [ref=e88]:
- generic [ref=e90]:
- generic [ref=e92] [cursor=pointer]:
- button "拖拽排序" [ref=e93]:
- img [ref=e94]
- generic [ref=e101]:
- button "展开或折叠" [ref=e102]:
- img [ref=e103]
- link "新页面1" [ref=e105]:
- /url: /documents/00d00779-068a-4721-902a-136c8c4ada66
- button "新建子页面" [ref=e106]:
- img [ref=e107]
- button "更多操作" [ref=e108]:
- img [ref=e109]
- generic [ref=e114] [cursor=pointer]:
- button "拖拽排序" [ref=e115]:
- img [ref=e116]
- link "无标题2" [ref=e125]:
- /url: /documents/54698dd5-41f4-45f2-89dd-c217c94aa5ca
- button "新建子页面" [ref=e126]:
- img [ref=e127]
- button "更多操作" [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- button "拖拽排序" [ref=e135]:
- img [ref=e136]
- link "无标题3" [ref=e145]:
- /url: /documents/744e666b-1220-40f5-8760-f73b8529a2b3
- button "新建子页面" [ref=e146]:
- img [ref=e147]
- button "更多操作" [ref=e148]:
- img [ref=e149]
- generic [ref=e154] [cursor=pointer]:
- button "拖拽排序" [ref=e155]:
- img [ref=e156]
- generic [ref=e163]:
- button "展开或折叠" [ref=e164]:
- img [ref=e165]
- link "debug-created" [ref=e167]:
- /url: /documents/dac243b0-d9a2-42f8-a08c-53c36da6ac66
- button "新建子页面" [ref=e168]:
- img [ref=e169]
- button "更多操作" [ref=e170]:
- img [ref=e171]
- generic [ref=e176] [cursor=pointer]:
- button "拖拽排序" [ref=e177]:
- img [ref=e178]
- link "debug-child" [ref=e187]:
- /url: /documents/314f1473-2106-49ca-9755-094960d972c5
- button "新建子页面" [ref=e188]:
- img [ref=e189]
- button "更多操作" [ref=e190]:
- img [ref=e191]
- generic [ref=e196] [cursor=pointer]:
- button "拖拽排序" [ref=e197]:
- img [ref=e198]
- generic [ref=e205]:
- button "展开或折叠" [ref=e206]:
- img [ref=e207]
- link "inspect-root" [ref=e209]:
- /url: /documents/5fb627bd-04f0-4709-8455-7ecb3e298c87
- button "新建子页面" [ref=e210]:
- img [ref=e211]
- button "更多操作" [ref=e212]:
- img [ref=e213]
- generic [ref=e218] [cursor=pointer]:
- button "拖拽排序" [ref=e219]:
- img [ref=e220]
- link "inspect-child" [ref=e229]:
- /url: /documents/a596ff02-6da9-45c7-9e93-eda0451f98e3
- button "新建子页面" [ref=e230]:
- img [ref=e231]
- button "更多操作" [ref=e232]:
- img [ref=e233]
- generic [ref=e238] [cursor=pointer]:
- button "拖拽排序" [ref=e239]:
- img [ref=e240]
- generic [ref=e247]:
- button "展开或折叠" [ref=e248]:
- img [ref=e249]
- link "inspect-root-2" [ref=e251]:
- /url: /documents/a41b9474-dad9-419b-a442-d9e2054964b1
- button "新建子页面" [ref=e252]:
- img [ref=e253]
- button "更多操作" [ref=e254]:
- img [ref=e255]
- generic [ref=e260] [cursor=pointer]:
- button "拖拽排序" [ref=e261]:
- img [ref=e262]
- link "inspect-child-2" [ref=e271]:
- /url: /documents/a4b44b6e-a405-4528-91fe-2eeeaada015c
- button "新建子页面" [ref=e272]:
- img [ref=e273]
- button "更多操作" [ref=e274]:
- img [ref=e275]
- generic [ref=e280] [cursor=pointer]:
- button "拖拽排序" [ref=e281]:
- img [ref=e282]
- link "无标题" [ref=e291]:
- /url: /documents/tree_1776472443143_1
- button "新建子页面" [ref=e292]:
- img [ref=e293]
- button "更多操作" [ref=e294]:
- img [ref=e295]
- generic [ref=e300] [cursor=pointer]:
- button "拖拽排序" [ref=e301]:
- img [ref=e302]
- generic [ref=e309]:
- button "展开或折叠" [ref=e310]:
- img [ref=e311]
- link "task091-root-1776472545755" [ref=e313]:
- /url: /documents/66dc6880-2dd7-4836-b7a7-ef6342560fd1
- button "新建子页面" [ref=e314]:
- img [ref=e315]
- button "更多操作" [ref=e316]:
- img [ref=e317]
- generic [ref=e322] [cursor=pointer]:
- button "拖拽排序" [ref=e323]:
- img [ref=e324]
- link "task091-child-1776472545755" [ref=e333]:
- /url: /documents/0c59283a-6a41-48e9-b0a5-dd2893ee8e84
- button "新建子页面" [ref=e334]:
- img [ref=e335]
- button "更多操作" [ref=e336]:
- img [ref=e337]
- generic [ref=e342] [cursor=pointer]:
- button "拖拽排序" [ref=e343]:
- img [ref=e344]
- link "无标题" [ref=e353]:
- /url: /documents/tree_1776472546826_2
- button "新建子页面" [ref=e354]:
- img [ref=e355]
- button "更多操作" [ref=e356]:
- img [ref=e357]
- generic [ref=e362] [cursor=pointer]:
- button "拖拽排序" [ref=e363]:
- img [ref=e364]
- link "无标题" [ref=e373]:
- /url: /documents/59ed732f-9ea1-4da8-b6e6-7a48ccb1f013
- button "新建子页面" [ref=e374]:
- img [ref=e375]
- button "更多操作" [ref=e376]:
- img [ref=e377]
- generic [ref=e382] [cursor=pointer]:
- button "拖拽排序" [ref=e383]:
- img [ref=e384]
- link "无标题" [ref=e393]:
- /url: /documents/55560f41-a497-47f5-9606-9f6848bd710e
- button "新建子页面" [ref=e394]:
- img [ref=e395]
- button "更多操作" [ref=e396]:
- img [ref=e397]
- generic [ref=e402] [cursor=pointer]:
- button "拖拽排序" [ref=e403]:
- img [ref=e404]
- link "无标题" [ref=e413]:
- /url: /documents/1b9785e5-9a1d-401d-9790-7f01eb9ea6d0
- button "新建子页面" [ref=e414]:
- img [ref=e415]
- button "更多操作" [ref=e416]:
- img [ref=e417]
- generic [ref=e422] [cursor=pointer]:
- button "拖拽排序" [ref=e423]:
- img [ref=e424]
- link "无标题" [ref=e433]:
- /url: /documents/575cae40-ef3b-48c3-bcbe-26ba1cffb274
- button "新建子页面" [ref=e434]:
- img [ref=e435]
- button "更多操作" [ref=e436]:
- img [ref=e437]
- generic [ref=e442] [cursor=pointer]:
- button "拖拽排序" [ref=e443]:
- img [ref=e444]
- link "无标题" [ref=e453]:
- /url: /documents/d6029c24-3597-4152-b3a6-94de16142595
- button "新建子页面" [ref=e454]:
- img [ref=e455]
- button "更多操作" [ref=e456]:
- img [ref=e457]
- generic [ref=e462] [cursor=pointer]:
- button "拖拽排序" [ref=e463]:
- img [ref=e464]
- link "无标题" [ref=e473]:
- /url: /documents/2da9bf74-65bc-4c25-9888-6aee81d6ed79
- button "新建子页面" [ref=e474]:
- img [ref=e475]
- button "更多操作" [ref=e476]:
- img [ref=e477]
- generic [ref=e482] [cursor=pointer]:
- button "拖拽排序" [ref=e483]:
- img [ref=e484]
- link "无标题" [ref=e493]:
- /url: /documents/f1a44544-284e-4948-8795-05f2446d7cd3
- button "新建子页面" [ref=e494]:
- img [ref=e495]
- button "更多操作" [ref=e496]:
- img [ref=e497]
- generic [ref=e502] [cursor=pointer]:
- button "拖拽排序" [ref=e503]:
- img [ref=e504]
- link "无标题" [ref=e513]:
- /url: /documents/a33ab29b-540e-43c3-8413-841a3fb9dc30
- button "新建子页面" [ref=e514]:
- img [ref=e515]
- button "更多操作" [ref=e516]:
- img [ref=e517]
- generic [ref=e522] [cursor=pointer]:
- button "拖拽排序" [ref=e523]:
- img [ref=e524]
- link "无标题" [ref=e533]:
- /url: /documents/67ca9680-231d-46a9-baed-1c13244e0724
- button "新建子页面" [ref=e534]:
- img [ref=e535]
- button "更多操作" [ref=e536]:
- img [ref=e537]
- generic [ref=e542] [cursor=pointer]:
- button "拖拽排序" [ref=e543]:
- img [ref=e544]
- link "无标题" [ref=e553]:
- /url: /documents/952a6c2f-6676-477c-8b2d-8e7f8fe594c8
- button "新建子页面" [ref=e554]:
- img [ref=e555]
- button "更多操作" [ref=e556]:
- img [ref=e557]
- generic [ref=e562] [cursor=pointer]:
- button "拖拽排序" [ref=e563]:
- img [ref=e564]
- link "无标题" [ref=e573]:
- /url: /documents/8337ceec-e236-41d7-a2eb-86838f46c858
- button "新建子页面" [ref=e574]:
- img [ref=e575]
- button "更多操作" [ref=e576]:
- img [ref=e577]
- generic [ref=e582] [cursor=pointer]:
- button "拖拽排序" [ref=e583]:
- img [ref=e584]
- link "无标题" [ref=e593]:
- /url: /documents/7c690634-4638-43eb-8bb4-ccfa79b94373
- button "新建子页面" [ref=e594]:
- img [ref=e595]
- button "更多操作" [ref=e596]:
- img [ref=e597]
- generic [ref=e602] [cursor=pointer]:
- button "拖拽排序" [ref=e603]:
- img [ref=e604]
- link "无标题" [ref=e613]:
- /url: /documents/153fde24-7692-4a38-a6e4-9187b5255ad5
- button "新建子页面" [ref=e614]:
- img [ref=e615]
- button "更多操作" [ref=e616]:
- img [ref=e617]
- generic [ref=e622] [cursor=pointer]:
- button "拖拽排序" [ref=e623]:
- img [ref=e624]
- link "无标题" [ref=e633]:
- /url: /documents/9a8f8d18-7c6a-4647-ab3b-7ac131eee6f4
- button "新建子页面" [ref=e634]:
- img [ref=e635]
- button "更多操作" [ref=e636]:
- img [ref=e637]
- generic [ref=e642] [cursor=pointer]:
- button "拖拽排序" [ref=e643]:
- img [ref=e644]
- link "无标题" [ref=e653]:
- /url: /documents/5d47bc40-ea69-466d-b79d-76a71bc0f76a
- button "新建子页面" [ref=e654]:
- img [ref=e655]
- button "更多操作" [ref=e656]:
- img [ref=e657]
- generic [ref=e662] [cursor=pointer]:
- button "拖拽排序" [ref=e663]:
- img [ref=e664]
- link "无标题" [ref=e673]:
- /url: /documents/14d71939-56f3-4de7-99b6-592b543d426f
- button "新建子页面" [ref=e674]:
- img [ref=e675]
- button "更多操作" [ref=e676]:
- img [ref=e677]
- status [ref=e681]
- generic [ref=e682]:
- button "新建页面" [ref=e683]:
- img
- text: 新建页面
- button "垃圾桶 1 条" [ref=e684]:
- generic [ref=e685]:
- img [ref=e686]
- text: 垃圾桶
- generic [ref=e689]: 1
- generic [ref=e691]:
- banner [ref=e692]:
- generic [ref=e693]:
- navigation [ref=e694]:
- generic [ref=e696]: 新页面1
- generic [ref=e697]:
- generic "后端连接状态" [ref=e698]
- button "页面AI" [ref=e699]:
- img [ref=e700]
- text: 页面AI
- button "收藏" [ref=e703]:
- img [ref=e704]
- text: 收藏
- button "显示页面选项" [ref=e706]:
- img [ref=e707]
- main [ref=e711]:
- generic [ref=e715]:
- generic [ref=e718]:
- textbox "页面标题" [ref=e720]:
- /placeholder: 无标题
- text: 新页面1
- paragraph [ref=e721]: 最近更新:2026/4/20 22:31:48
- generic [ref=e725]: 编辑器加载中...
- button "Open Next.js Dev Tools" [ref=e731] [cursor=pointer]:
- img [ref=e732]
- alert [ref=e735]
@@ -0,0 +1,368 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]: 当前工作空间
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: 开发用户 的空间
- generic [ref=e11]: 个人空间
- button "切换(暂停)" [ref=e12]
- generic [ref=e13]: 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
- button "退出登录" [ref=e15]:
- img [ref=e16]
- generic [ref=e19]: 退出登录
- generic [ref=e20]:
- button "搜索" [ref=e21]:
- img [ref=e22]
- generic [ref=e25]: 搜索
- button "关系图" [ref=e26]:
- img [ref=e27]
- generic [ref=e33]: 关系图
- button "导入" [ref=e34]:
- img [ref=e35]
- generic [ref=e38]: 导入
- button "成员" [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: 成员
- button "星标置顶" [ref=e46]:
- img [ref=e47]
- generic [ref=e49]: 星标置顶
- button "公共页面" [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: 公共页面
- button "共享页面" [ref=e55]:
- img [ref=e56]
- generic [ref=e58]: 共享页面
- button "模板中心" [ref=e59]:
- img [ref=e60]
- generic [ref=e65]: 模板中心
- generic [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- img [ref=e70]
- text: 页面树
- generic [ref=e72]:
- textbox "搜索页面" [ref=e73]
- generic [ref=e74]:
- button "分组" [ref=e75]
- button "文件" [ref=e76]
- generic [ref=e77]:
- button "私有 / 我的页面" [ref=e78]:
- generic [ref=e79]: 私有 / 我的页面
- img [ref=e80]
- generic [ref=e88]:
- generic [ref=e90]:
- generic [ref=e92] [cursor=pointer]:
- button "拖拽排序" [ref=e93]:
- img [ref=e94]
- generic [ref=e101]:
- button "展开或折叠" [ref=e102]:
- img [ref=e103]
- link "新页面1" [ref=e105]:
- /url: /documents/00d00779-068a-4721-902a-136c8c4ada66
- button "新建子页面" [ref=e106]:
- img [ref=e107]
- button "更多操作" [ref=e108]:
- img [ref=e109]
- generic [ref=e114] [cursor=pointer]:
- button "拖拽排序" [ref=e115]:
- img [ref=e116]
- link "无标题2" [ref=e125]:
- /url: /documents/54698dd5-41f4-45f2-89dd-c217c94aa5ca
- button "新建子页面" [ref=e126]:
- img [ref=e127]
- button "更多操作" [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- button "拖拽排序" [ref=e135]:
- img [ref=e136]
- link "无标题3" [ref=e145]:
- /url: /documents/744e666b-1220-40f5-8760-f73b8529a2b3
- button "新建子页面" [ref=e146]:
- img [ref=e147]
- button "更多操作" [ref=e148]:
- img [ref=e149]
- generic [ref=e154] [cursor=pointer]:
- button "拖拽排序" [ref=e155]:
- img [ref=e156]
- generic [ref=e163]:
- button "展开或折叠" [ref=e164]:
- img [ref=e165]
- link "debug-created" [ref=e167]:
- /url: /documents/dac243b0-d9a2-42f8-a08c-53c36da6ac66
- button "新建子页面" [ref=e168]:
- img [ref=e169]
- button "更多操作" [ref=e170]:
- img [ref=e171]
- generic [ref=e176] [cursor=pointer]:
- button "拖拽排序" [ref=e177]:
- img [ref=e178]
- link "debug-child" [ref=e187]:
- /url: /documents/314f1473-2106-49ca-9755-094960d972c5
- button "新建子页面" [ref=e188]:
- img [ref=e189]
- button "更多操作" [ref=e190]:
- img [ref=e191]
- generic [ref=e196] [cursor=pointer]:
- button "拖拽排序" [ref=e197]:
- img [ref=e198]
- generic [ref=e205]:
- button "展开或折叠" [ref=e206]:
- img [ref=e207]
- link "inspect-root" [ref=e209]:
- /url: /documents/5fb627bd-04f0-4709-8455-7ecb3e298c87
- button "新建子页面" [ref=e210]:
- img [ref=e211]
- button "更多操作" [ref=e212]:
- img [ref=e213]
- generic [ref=e218] [cursor=pointer]:
- button "拖拽排序" [ref=e219]:
- img [ref=e220]
- link "inspect-child" [ref=e229]:
- /url: /documents/a596ff02-6da9-45c7-9e93-eda0451f98e3
- button "新建子页面" [ref=e230]:
- img [ref=e231]
- button "更多操作" [ref=e232]:
- img [ref=e233]
- generic [ref=e238] [cursor=pointer]:
- button "拖拽排序" [ref=e239]:
- img [ref=e240]
- generic [ref=e247]:
- button "展开或折叠" [ref=e248]:
- img [ref=e249]
- link "inspect-root-2" [ref=e251]:
- /url: /documents/a41b9474-dad9-419b-a442-d9e2054964b1
- button "新建子页面" [ref=e252]:
- img [ref=e253]
- button "更多操作" [ref=e254]:
- img [ref=e255]
- generic [ref=e260] [cursor=pointer]:
- button "拖拽排序" [ref=e261]:
- img [ref=e262]
- link "inspect-child-2" [ref=e271]:
- /url: /documents/a4b44b6e-a405-4528-91fe-2eeeaada015c
- button "新建子页面" [ref=e272]:
- img [ref=e273]
- button "更多操作" [ref=e274]:
- img [ref=e275]
- generic [ref=e280] [cursor=pointer]:
- button "拖拽排序" [ref=e281]:
- img [ref=e282]
- link "无标题" [ref=e291]:
- /url: /documents/tree_1776472443143_1
- button "新建子页面" [ref=e292]:
- img [ref=e293]
- button "更多操作" [ref=e294]:
- img [ref=e295]
- generic [ref=e300] [cursor=pointer]:
- button "拖拽排序" [ref=e301]:
- img [ref=e302]
- generic [ref=e309]:
- button "展开或折叠" [ref=e310]:
- img [ref=e311]
- link "task091-root-1776472545755" [ref=e313]:
- /url: /documents/66dc6880-2dd7-4836-b7a7-ef6342560fd1
- button "新建子页面" [ref=e314]:
- img [ref=e315]
- button "更多操作" [ref=e316]:
- img [ref=e317]
- generic [ref=e322] [cursor=pointer]:
- button "拖拽排序" [ref=e323]:
- img [ref=e324]
- link "task091-child-1776472545755" [ref=e333]:
- /url: /documents/0c59283a-6a41-48e9-b0a5-dd2893ee8e84
- button "新建子页面" [ref=e334]:
- img [ref=e335]
- button "更多操作" [ref=e336]:
- img [ref=e337]
- generic [ref=e342] [cursor=pointer]:
- button "拖拽排序" [ref=e343]:
- img [ref=e344]
- link "无标题" [ref=e353]:
- /url: /documents/tree_1776472546826_2
- button "新建子页面" [ref=e354]:
- img [ref=e355]
- button "更多操作" [ref=e356]:
- img [ref=e357]
- generic [ref=e362] [cursor=pointer]:
- button "拖拽排序" [ref=e363]:
- img [ref=e364]
- link "无标题" [ref=e373]:
- /url: /documents/59ed732f-9ea1-4da8-b6e6-7a48ccb1f013
- button "新建子页面" [ref=e374]:
- img [ref=e375]
- button "更多操作" [ref=e376]:
- img [ref=e377]
- generic [ref=e382] [cursor=pointer]:
- button "拖拽排序" [ref=e383]:
- img [ref=e384]
- link "无标题" [ref=e393]:
- /url: /documents/55560f41-a497-47f5-9606-9f6848bd710e
- button "新建子页面" [ref=e394]:
- img [ref=e395]
- button "更多操作" [ref=e396]:
- img [ref=e397]
- generic [ref=e402] [cursor=pointer]:
- button "拖拽排序" [ref=e403]:
- img [ref=e404]
- link "无标题" [ref=e413]:
- /url: /documents/1b9785e5-9a1d-401d-9790-7f01eb9ea6d0
- button "新建子页面" [ref=e414]:
- img [ref=e415]
- button "更多操作" [ref=e416]:
- img [ref=e417]
- generic [ref=e422] [cursor=pointer]:
- button "拖拽排序" [ref=e423]:
- img [ref=e424]
- link "无标题" [ref=e433]:
- /url: /documents/575cae40-ef3b-48c3-bcbe-26ba1cffb274
- button "新建子页面" [ref=e434]:
- img [ref=e435]
- button "更多操作" [ref=e436]:
- img [ref=e437]
- generic [ref=e442] [cursor=pointer]:
- button "拖拽排序" [ref=e443]:
- img [ref=e444]
- link "无标题" [ref=e453]:
- /url: /documents/d6029c24-3597-4152-b3a6-94de16142595
- button "新建子页面" [ref=e454]:
- img [ref=e455]
- button "更多操作" [ref=e456]:
- img [ref=e457]
- generic [ref=e462] [cursor=pointer]:
- button "拖拽排序" [ref=e463]:
- img [ref=e464]
- link "无标题" [ref=e473]:
- /url: /documents/2da9bf74-65bc-4c25-9888-6aee81d6ed79
- button "新建子页面" [ref=e474]:
- img [ref=e475]
- button "更多操作" [ref=e476]:
- img [ref=e477]
- generic [ref=e482] [cursor=pointer]:
- button "拖拽排序" [ref=e483]:
- img [ref=e484]
- link "无标题" [ref=e493]:
- /url: /documents/f1a44544-284e-4948-8795-05f2446d7cd3
- button "新建子页面" [ref=e494]:
- img [ref=e495]
- button "更多操作" [ref=e496]:
- img [ref=e497]
- generic [ref=e502] [cursor=pointer]:
- button "拖拽排序" [ref=e503]:
- img [ref=e504]
- link "无标题" [ref=e513]:
- /url: /documents/a33ab29b-540e-43c3-8413-841a3fb9dc30
- button "新建子页面" [ref=e514]:
- img [ref=e515]
- button "更多操作" [ref=e516]:
- img [ref=e517]
- generic [ref=e522] [cursor=pointer]:
- button "拖拽排序" [ref=e523]:
- img [ref=e524]
- link "无标题" [ref=e533]:
- /url: /documents/67ca9680-231d-46a9-baed-1c13244e0724
- button "新建子页面" [ref=e534]:
- img [ref=e535]
- button "更多操作" [ref=e536]:
- img [ref=e537]
- generic [ref=e542] [cursor=pointer]:
- button "拖拽排序" [ref=e543]:
- img [ref=e544]
- link "无标题" [ref=e553]:
- /url: /documents/952a6c2f-6676-477c-8b2d-8e7f8fe594c8
- button "新建子页面" [ref=e554]:
- img [ref=e555]
- button "更多操作" [ref=e556]:
- img [ref=e557]
- generic [ref=e562] [cursor=pointer]:
- button "拖拽排序" [ref=e563]:
- img [ref=e564]
- link "无标题" [ref=e573]:
- /url: /documents/8337ceec-e236-41d7-a2eb-86838f46c858
- button "新建子页面" [ref=e574]:
- img [ref=e575]
- button "更多操作" [ref=e576]:
- img [ref=e577]
- generic [ref=e582] [cursor=pointer]:
- button "拖拽排序" [ref=e583]:
- img [ref=e584]
- link "无标题" [ref=e593]:
- /url: /documents/7c690634-4638-43eb-8bb4-ccfa79b94373
- button "新建子页面" [ref=e594]:
- img [ref=e595]
- button "更多操作" [ref=e596]:
- img [ref=e597]
- generic [ref=e602] [cursor=pointer]:
- button "拖拽排序" [ref=e603]:
- img [ref=e604]
- link "无标题" [ref=e613]:
- /url: /documents/153fde24-7692-4a38-a6e4-9187b5255ad5
- button "新建子页面" [ref=e614]:
- img [ref=e615]
- button "更多操作" [ref=e616]:
- img [ref=e617]
- generic [ref=e622] [cursor=pointer]:
- button "拖拽排序" [ref=e623]:
- img [ref=e624]
- link "无标题" [ref=e633]:
- /url: /documents/9a8f8d18-7c6a-4647-ab3b-7ac131eee6f4
- button "新建子页面" [ref=e634]:
- img [ref=e635]
- button "更多操作" [ref=e636]:
- img [ref=e637]
- generic [ref=e642] [cursor=pointer]:
- button "拖拽排序" [ref=e643]:
- img [ref=e644]
- link "无标题" [ref=e653]:
- /url: /documents/5d47bc40-ea69-466d-b79d-76a71bc0f76a
- button "新建子页面" [ref=e654]:
- img [ref=e655]
- button "更多操作" [ref=e656]:
- img [ref=e657]
- generic [ref=e662] [cursor=pointer]:
- button "拖拽排序" [ref=e663]:
- img [ref=e664]
- link "无标题" [ref=e673]:
- /url: /documents/14d71939-56f3-4de7-99b6-592b543d426f
- button "新建子页面" [ref=e674]:
- img [ref=e675]
- button "更多操作" [ref=e676]:
- img [ref=e677]
- status [ref=e681]
- generic [ref=e682]:
- button "新建页面" [ref=e683]:
- img
- text: 新建页面
- button "垃圾桶 1 条" [ref=e684]:
- generic [ref=e685]:
- img [ref=e686]
- text: 垃圾桶
- generic [ref=e689]: 1
- generic [ref=e691]:
- banner [ref=e692]:
- generic [ref=e693]:
- navigation [ref=e694]:
- generic [ref=e696]: 新页面1
- generic [ref=e697]:
- generic "后端连接状态" [ref=e698]
- button "页面AI" [disabled] [ref=e699]:
- img [ref=e700]
- text: 页面AI
- button "收藏" [disabled] [ref=e703]:
- img [ref=e704]
- text: 收藏
- button "显示页面选项" [ref=e706]:
- img [ref=e707]
- main [ref=e711]:
- generic [ref=e715]:
- generic [ref=e718]:
- textbox "页面标题" [ref=e720]:
- /placeholder: 无标题
- text: 新页面1
- paragraph [ref=e721]: 最近更新:2026/4/20 22:32:29
- generic [ref=e725]: 编辑器加载中...
- button "Open Next.js Dev Tools" [ref=e731] [cursor=pointer]:
- img [ref=e732]
- alert [ref=e735]
@@ -0,0 +1,368 @@
- generic [active] [ref=e1]:
- generic [ref=e2]:
- complementary [ref=e3]:
- generic [ref=e5]:
- generic [ref=e6]:
- generic [ref=e7]: 当前工作空间
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e10]: 开发用户 的空间
- generic [ref=e11]: 个人空间
- button "切换(暂停)" [ref=e12]
- generic [ref=e13]: 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
- button "退出登录" [ref=e15]:
- img [ref=e16]
- generic [ref=e19]: 退出登录
- generic [ref=e20]:
- button "搜索" [ref=e21]:
- img [ref=e22]
- generic [ref=e25]: 搜索
- button "关系图" [ref=e26]:
- img [ref=e27]
- generic [ref=e33]: 关系图
- button "导入" [ref=e34]:
- img [ref=e35]
- generic [ref=e38]: 导入
- button "成员" [ref=e39]:
- img [ref=e40]
- generic [ref=e45]: 成员
- button "星标置顶" [ref=e46]:
- img [ref=e47]
- generic [ref=e49]: 星标置顶
- button "公共页面" [ref=e50]:
- img [ref=e51]
- generic [ref=e54]: 公共页面
- button "共享页面" [ref=e55]:
- img [ref=e56]
- generic [ref=e58]: 共享页面
- button "模板中心" [ref=e59]:
- img [ref=e60]
- generic [ref=e65]: 模板中心
- generic [ref=e67]:
- generic [ref=e68]:
- generic [ref=e69]:
- img [ref=e70]
- text: 页面树
- generic [ref=e72]:
- textbox "搜索页面" [ref=e73]
- generic [ref=e74]:
- button "分组" [ref=e75]
- button "文件" [ref=e76]
- generic [ref=e77]:
- button "私有 / 我的页面" [ref=e78]:
- generic [ref=e79]: 私有 / 我的页面
- img [ref=e80]
- generic [ref=e88]:
- generic [ref=e90]:
- generic [ref=e92] [cursor=pointer]:
- button "拖拽排序" [ref=e93]:
- img [ref=e94]
- generic [ref=e101]:
- button "展开或折叠" [ref=e102]:
- img [ref=e103]
- link "新页面1" [ref=e105]:
- /url: /documents/00d00779-068a-4721-902a-136c8c4ada66
- button "新建子页面" [ref=e106]:
- img [ref=e107]
- button "更多操作" [ref=e108]:
- img [ref=e109]
- generic [ref=e114] [cursor=pointer]:
- button "拖拽排序" [ref=e115]:
- img [ref=e116]
- link "无标题2" [ref=e125]:
- /url: /documents/54698dd5-41f4-45f2-89dd-c217c94aa5ca
- button "新建子页面" [ref=e126]:
- img [ref=e127]
- button "更多操作" [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- button "拖拽排序" [ref=e135]:
- img [ref=e136]
- link "无标题3" [ref=e145]:
- /url: /documents/744e666b-1220-40f5-8760-f73b8529a2b3
- button "新建子页面" [ref=e146]:
- img [ref=e147]
- button "更多操作" [ref=e148]:
- img [ref=e149]
- generic [ref=e154] [cursor=pointer]:
- button "拖拽排序" [ref=e155]:
- img [ref=e156]
- generic [ref=e163]:
- button "展开或折叠" [ref=e164]:
- img [ref=e165]
- link "debug-created" [ref=e167]:
- /url: /documents/dac243b0-d9a2-42f8-a08c-53c36da6ac66
- button "新建子页面" [ref=e168]:
- img [ref=e169]
- button "更多操作" [ref=e170]:
- img [ref=e171]
- generic [ref=e176] [cursor=pointer]:
- button "拖拽排序" [ref=e177]:
- img [ref=e178]
- link "debug-child" [ref=e187]:
- /url: /documents/314f1473-2106-49ca-9755-094960d972c5
- button "新建子页面" [ref=e188]:
- img [ref=e189]
- button "更多操作" [ref=e190]:
- img [ref=e191]
- generic [ref=e196] [cursor=pointer]:
- button "拖拽排序" [ref=e197]:
- img [ref=e198]
- generic [ref=e205]:
- button "展开或折叠" [ref=e206]:
- img [ref=e207]
- link "inspect-root" [ref=e209]:
- /url: /documents/5fb627bd-04f0-4709-8455-7ecb3e298c87
- button "新建子页面" [ref=e210]:
- img [ref=e211]
- button "更多操作" [ref=e212]:
- img [ref=e213]
- generic [ref=e218] [cursor=pointer]:
- button "拖拽排序" [ref=e219]:
- img [ref=e220]
- link "inspect-child" [ref=e229]:
- /url: /documents/a596ff02-6da9-45c7-9e93-eda0451f98e3
- button "新建子页面" [ref=e230]:
- img [ref=e231]
- button "更多操作" [ref=e232]:
- img [ref=e233]
- generic [ref=e238] [cursor=pointer]:
- button "拖拽排序" [ref=e239]:
- img [ref=e240]
- generic [ref=e247]:
- button "展开或折叠" [ref=e248]:
- img [ref=e249]
- link "inspect-root-2" [ref=e251]:
- /url: /documents/a41b9474-dad9-419b-a442-d9e2054964b1
- button "新建子页面" [ref=e252]:
- img [ref=e253]
- button "更多操作" [ref=e254]:
- img [ref=e255]
- generic [ref=e260] [cursor=pointer]:
- button "拖拽排序" [ref=e261]:
- img [ref=e262]
- link "inspect-child-2" [ref=e271]:
- /url: /documents/a4b44b6e-a405-4528-91fe-2eeeaada015c
- button "新建子页面" [ref=e272]:
- img [ref=e273]
- button "更多操作" [ref=e274]:
- img [ref=e275]
- generic [ref=e280] [cursor=pointer]:
- button "拖拽排序" [ref=e281]:
- img [ref=e282]
- link "无标题" [ref=e291]:
- /url: /documents/tree_1776472443143_1
- button "新建子页面" [ref=e292]:
- img [ref=e293]
- button "更多操作" [ref=e294]:
- img [ref=e295]
- generic [ref=e300] [cursor=pointer]:
- button "拖拽排序" [ref=e301]:
- img [ref=e302]
- generic [ref=e309]:
- button "展开或折叠" [ref=e310]:
- img [ref=e311]
- link "task091-root-1776472545755" [ref=e313]:
- /url: /documents/66dc6880-2dd7-4836-b7a7-ef6342560fd1
- button "新建子页面" [ref=e314]:
- img [ref=e315]
- button "更多操作" [ref=e316]:
- img [ref=e317]
- generic [ref=e322] [cursor=pointer]:
- button "拖拽排序" [ref=e323]:
- img [ref=e324]
- link "task091-child-1776472545755" [ref=e333]:
- /url: /documents/0c59283a-6a41-48e9-b0a5-dd2893ee8e84
- button "新建子页面" [ref=e334]:
- img [ref=e335]
- button "更多操作" [ref=e336]:
- img [ref=e337]
- generic [ref=e342] [cursor=pointer]:
- button "拖拽排序" [ref=e343]:
- img [ref=e344]
- link "无标题" [ref=e353]:
- /url: /documents/tree_1776472546826_2
- button "新建子页面" [ref=e354]:
- img [ref=e355]
- button "更多操作" [ref=e356]:
- img [ref=e357]
- generic [ref=e362] [cursor=pointer]:
- button "拖拽排序" [ref=e363]:
- img [ref=e364]
- link "无标题" [ref=e373]:
- /url: /documents/59ed732f-9ea1-4da8-b6e6-7a48ccb1f013
- button "新建子页面" [ref=e374]:
- img [ref=e375]
- button "更多操作" [ref=e376]:
- img [ref=e377]
- generic [ref=e382] [cursor=pointer]:
- button "拖拽排序" [ref=e383]:
- img [ref=e384]
- link "无标题" [ref=e393]:
- /url: /documents/55560f41-a497-47f5-9606-9f6848bd710e
- button "新建子页面" [ref=e394]:
- img [ref=e395]
- button "更多操作" [ref=e396]:
- img [ref=e397]
- generic [ref=e402] [cursor=pointer]:
- button "拖拽排序" [ref=e403]:
- img [ref=e404]
- link "无标题" [ref=e413]:
- /url: /documents/1b9785e5-9a1d-401d-9790-7f01eb9ea6d0
- button "新建子页面" [ref=e414]:
- img [ref=e415]
- button "更多操作" [ref=e416]:
- img [ref=e417]
- generic [ref=e422] [cursor=pointer]:
- button "拖拽排序" [ref=e423]:
- img [ref=e424]
- link "无标题" [ref=e433]:
- /url: /documents/575cae40-ef3b-48c3-bcbe-26ba1cffb274
- button "新建子页面" [ref=e434]:
- img [ref=e435]
- button "更多操作" [ref=e436]:
- img [ref=e437]
- generic [ref=e442] [cursor=pointer]:
- button "拖拽排序" [ref=e443]:
- img [ref=e444]
- link "无标题" [ref=e453]:
- /url: /documents/d6029c24-3597-4152-b3a6-94de16142595
- button "新建子页面" [ref=e454]:
- img [ref=e455]
- button "更多操作" [ref=e456]:
- img [ref=e457]
- generic [ref=e462] [cursor=pointer]:
- button "拖拽排序" [ref=e463]:
- img [ref=e464]
- link "无标题" [ref=e473]:
- /url: /documents/2da9bf74-65bc-4c25-9888-6aee81d6ed79
- button "新建子页面" [ref=e474]:
- img [ref=e475]
- button "更多操作" [ref=e476]:
- img [ref=e477]
- generic [ref=e482] [cursor=pointer]:
- button "拖拽排序" [ref=e483]:
- img [ref=e484]
- link "无标题" [ref=e493]:
- /url: /documents/f1a44544-284e-4948-8795-05f2446d7cd3
- button "新建子页面" [ref=e494]:
- img [ref=e495]
- button "更多操作" [ref=e496]:
- img [ref=e497]
- generic [ref=e502] [cursor=pointer]:
- button "拖拽排序" [ref=e503]:
- img [ref=e504]
- link "无标题" [ref=e513]:
- /url: /documents/a33ab29b-540e-43c3-8413-841a3fb9dc30
- button "新建子页面" [ref=e514]:
- img [ref=e515]
- button "更多操作" [ref=e516]:
- img [ref=e517]
- generic [ref=e522] [cursor=pointer]:
- button "拖拽排序" [ref=e523]:
- img [ref=e524]
- link "无标题" [ref=e533]:
- /url: /documents/67ca9680-231d-46a9-baed-1c13244e0724
- button "新建子页面" [ref=e534]:
- img [ref=e535]
- button "更多操作" [ref=e536]:
- img [ref=e537]
- generic [ref=e542] [cursor=pointer]:
- button "拖拽排序" [ref=e543]:
- img [ref=e544]
- link "无标题" [ref=e553]:
- /url: /documents/952a6c2f-6676-477c-8b2d-8e7f8fe594c8
- button "新建子页面" [ref=e554]:
- img [ref=e555]
- button "更多操作" [ref=e556]:
- img [ref=e557]
- generic [ref=e562] [cursor=pointer]:
- button "拖拽排序" [ref=e563]:
- img [ref=e564]
- link "无标题" [ref=e573]:
- /url: /documents/8337ceec-e236-41d7-a2eb-86838f46c858
- button "新建子页面" [ref=e574]:
- img [ref=e575]
- button "更多操作" [ref=e576]:
- img [ref=e577]
- generic [ref=e582] [cursor=pointer]:
- button "拖拽排序" [ref=e583]:
- img [ref=e584]
- link "无标题" [ref=e593]:
- /url: /documents/7c690634-4638-43eb-8bb4-ccfa79b94373
- button "新建子页面" [ref=e594]:
- img [ref=e595]
- button "更多操作" [ref=e596]:
- img [ref=e597]
- generic [ref=e602] [cursor=pointer]:
- button "拖拽排序" [ref=e603]:
- img [ref=e604]
- link "无标题" [ref=e613]:
- /url: /documents/153fde24-7692-4a38-a6e4-9187b5255ad5
- button "新建子页面" [ref=e614]:
- img [ref=e615]
- button "更多操作" [ref=e616]:
- img [ref=e617]
- generic [ref=e622] [cursor=pointer]:
- button "拖拽排序" [ref=e623]:
- img [ref=e624]
- link "无标题" [ref=e633]:
- /url: /documents/9a8f8d18-7c6a-4647-ab3b-7ac131eee6f4
- button "新建子页面" [ref=e634]:
- img [ref=e635]
- button "更多操作" [ref=e636]:
- img [ref=e637]
- generic [ref=e642] [cursor=pointer]:
- button "拖拽排序" [ref=e643]:
- img [ref=e644]
- link "无标题" [ref=e653]:
- /url: /documents/5d47bc40-ea69-466d-b79d-76a71bc0f76a
- button "新建子页面" [ref=e654]:
- img [ref=e655]
- button "更多操作" [ref=e656]:
- img [ref=e657]
- generic [ref=e662] [cursor=pointer]:
- button "拖拽排序" [ref=e663]:
- img [ref=e664]
- link "无标题" [ref=e673]:
- /url: /documents/14d71939-56f3-4de7-99b6-592b543d426f
- button "新建子页面" [ref=e674]:
- img [ref=e675]
- button "更多操作" [ref=e676]:
- img [ref=e677]
- status [ref=e681]
- generic [ref=e682]:
- button "新建页面" [ref=e683]:
- img
- text: 新建页面
- button "垃圾桶 1 条" [ref=e684]:
- generic [ref=e685]:
- img [ref=e686]
- text: 垃圾桶
- generic [ref=e689]: 1
- generic [ref=e691]:
- banner [ref=e692]:
- generic [ref=e693]:
- navigation [ref=e694]:
- generic [ref=e696]: 新页面1
- generic [ref=e697]:
- generic "后端连接状态" [ref=e698]
- button "页面AI" [disabled] [ref=e699]:
- img [ref=e700]
- text: 页面AI
- button "收藏" [disabled] [ref=e703]:
- img [ref=e704]
- text: 收藏
- button "显示页面选项" [ref=e706]:
- img [ref=e707]
- main [ref=e711]:
- generic [ref=e715]:
- generic [ref=e718]:
- textbox "页面标题" [ref=e720]:
- /placeholder: 无标题
- text: 新页面1
- paragraph [ref=e721]: 最近更新:2026/4/20 22:46:15
- generic [ref=e725]: 编辑器加载中...
- button "Open Next.js Dev Tools" [ref=e731] [cursor=pointer]:
- img [ref=e732]
- alert [ref=e735]
+3
View File
@@ -0,0 +1,3 @@
version = "12"
[overrides]
+11 -3
View File
@@ -42,9 +42,17 @@
- `mnote-web``compat route` 可以承接过渡流量,但不要把新的长期业务逻辑继续堆进 compat。
- 需要架构判断时,优先参考:
- `/mnt/Data1T/mnote/ARCHITECTURE.md`
- `/mnt/Data1T/mnote/design/tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/tree-first-graph-kernel-checklist-v2.md`
- `/mnt/Data1T/mnote/design/tree-first-graph-convex-rust-long-term-architecture-v1.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-tree-first-graph-kernel-v1.md`
- `/mnt/Data1T/mnote/design/01-tree-first-graph-kernel/process/1-1-tree-first-graph-kernel-checklist-v2.md`
- `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
## 设计稿目录规则
- `design/01-*``design/07-*` 主线大类统一按 `process/``done/` 分层。
- 主线设计稿在推进中时放到对应大类的 `process/`
- 主线设计稿在当前真实代码中已完成后,必须移动到对应大类的 `done/`
- `design/old/` 下的历史废弃稿也统一按 `process/``done/` 分层,但标题继续标记 `[recycle]`
- `design/90-reference/` 只放参考资料,不参与 `process/done` 状态迁移。
## 协作边界
+18 -221
View File
@@ -1,221 +1,18 @@
[2026-04-17T07:58:16Z] [SESSION-0] INIT Archived previous harness state to /mnt/Data1T/mnote/tmp/harness-backups/20260417T075718Z-pre-longterm-reset
[2026-04-17T07:58:16Z] [SESSION-0] INIT Initialized new harness batch from /mnt/Data1T/mnote/design/tree-first-graph-convex-rust-long-term-architecture-v1.md
[2026-04-17T08:02:25Z] [SESSION-1] LOCK acquired (pid=640651)
[2026-04-17T08:02:25Z] [SESSION-1] INIT Environment health check: PASS
[2026-04-17T08:02:25Z] [SESSION-1] Starting [task-001] 执行 Stage A-1:固定首页与 /api/sidebar 主路径只走 Convex/Rust 主链,禁止默认回落到 mnote-web compat/sidebar,并把 tree shell 默认运行态切成显式开关 (base=4538f85d)
[2026-04-17T08:03:15Z] [SESSION-1] CHECKPOINT [task-001] step=1/1 "已验证 /api/sidebar 不再默认走 mnote-web compattree shell 默认改为显式关闭,主链边界符合长期架构文档。"
[2026-04-17T08:03:15Z] [SESSION-1] Completed [task-001] (commit skipped by repo rule; base=4538f85d)
[2026-04-17T08:03:15Z] [SESSION-1] Starting [task-002] 执行 Stage A-2:收紧 move/embed picker 的实验壳边界,tree shell 关闭时不再额外请求 viewer/tree-shell 依赖,空查询路径继续只消费稳定 projection 数据 (base=4538f85d)
[2026-04-17T08:06:06Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已让 move/embed picker 在 tree shell 默认关闭时直接走稳定 projection fallback,不再额外请求 viewer/tree-shell 依赖。"
[2026-04-17T08:06:06Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; base=4538f85d)
[2026-04-17T08:06:06Z] [SESSION-1] Starting [task-003] 执行 Stage A-3:新增并跑通 3000 首页入口 smoke,验证在 3104 不可用时首页主链仍可进入 /auth 或已登录文档页,不因实验壳阻塞 (base=4538f85d)
[2026-04-17T08:12:27Z] [SESSION-1] CHECKPOINT [task-003] step=1/1 "已新增 task097 首页入口 smoke,并验证 3104 不可达时 3000 仍可进入 /auth;问题落在 dev 进程健康度,而非首页主链再次依赖实验壳。"
[2026-04-17T08:12:27Z] [SESSION-1] Completed [task-003] (commit skipped by repo rule; base=4538f85d)
[2026-04-17T08:12:27Z] [SESSION-1] Starting [task-004] 执行 Stage B-1:继续收缩前端树域真相泄漏,确认主路径 consumer 只消费 kernel projection,兼容 helper 不再回到运行时主链 (base=4538f85d)
[2026-04-17T08:11:55Z] [SESSION-1] CHECKPOINT [task-004] step=1/1 "已确认 sidebar / picker / host 主路径不再回退到 buildDocumentTree;树域主消费面继续停留在 kernel projection 上。"
[2026-04-17T08:11:55Z] [SESSION-1] Completed [task-004] (commit skipped by repo rule; base=4538f85d)
[2026-04-17T08:11:55Z] [SESSION-1] Starting [task-005] 执行 Stage B-2:梳理 sidebar/page/filetree 的 create/rename/move 命令入口,给出并落第一批 Rust command envelope 收口边界,避免前端继续扩散树语义 (base=4538f85d)
[2026-04-17T08:28:00Z] [SESSION-2] LOCK acquired (pid=resume)
[2026-04-17T08:28:10Z] [SESSION-2] INIT Resumed long-term harness batch after state cleanup; fixed harness-tasks.json quoting corruption and removed stale harness-tasks.json.tmp.
[2026-04-17T08:40:10Z] [SESSION-2] CHECKPOINT [task-005] step=1/2 "已完成 tree command 入口与 Rust envelope 接缝审计,并把 create/rename/move 的前端直连入口收成共享 tree command client。"
[2026-04-17T08:40:10Z] [SESSION-2] Completed [task-005] (commit skipped by repo rule; base=4538f85d)
[2026-04-17T08:42:00Z] [SESSION-2] CHECKPOINT [task-006] step=1/1 "已产出 Rust Web 正式实时树事件流方案文档,明确 Convex substrate 与 Rust semantic owner 的职责边界。"
[2026-04-17T08:42:00Z] [SESSION-2] Completed [task-006] (commit skipped by repo rule; base=4538f85d)
[2026-04-17T08:55:41Z] [SESSION-2] CHECKPOINT [task-007] step=1/2 "已定位 3000 无响应根因为 wolai-frontend/scripts/dev-server.js 的 Convex HTTP 代理未显式结束无请求体转发,导致 /convex/* 与 /auth 挂起。"
[2026-04-17T08:56:10Z] [SESSION-2] CHECKPOINT [task-007] step=2/2 "已修复 dev server 的 Convex HTTP 代理并完成回归验证:/convex/version=200, /auth=200, 首页 smoke 通过。"
[2026-04-17T08:56:30Z] [SESSION-2] Completed [task-007] (commit skipped by repo rule; base=4538f85d)
[2026-04-17T08:56:30Z] [SESSION-2] STATS tasks_total=7 completed=7 failed=0 pending=0 blocked=0 attempts_total=7 checkpoints=8
[2026-04-17T08:56:30Z] [SESSION-2] LOCK released
[2026-04-17T15:52:02Z] [SESSION-3] LOCK acquired (pid=1025059)
[2026-04-17T15:52:02Z] [SESSION-3] INIT Harness session started for pending tasks task-008..task-020
[2026-04-17T15:52:02Z] [SESSION-3] INIT Environment health check: PASS
[2026-04-17T15:52:02Z] [SESSION-3] Starting [task-008] 执行 Stage P0-1:补首页/sidebar/tree shell 主链回归护栏,固化 3000 首屏、Convex live 优先与实验壳显式开关约束 (base=aa6ee384)
[2026-04-17T15:54:10Z] [SESSION-3] CHECKPOINT [task-008] step=1/1 "已验证 Convex live 优先、tree shell 显式门控与首页 smoke3104 不可达时 3000 仍稳定回到 /auth。"
[2026-04-17T15:54:10Z] [SESSION-3] Completed [task-008] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T15:54:10Z] [SESSION-3] Starting [task-009] 执行 Stage P1-1:把 delete/restore/purge 收口到 shared tree command client,并让 Sidebar/DocumentContent 退出对应直调 route (base=aa6ee384)
[2026-04-17T15:58:30Z] [SESSION-3] CHECKPOINT [task-009] step=1/1 "已补 shared tree command client 的 delete/restore/purge,并移除 Sidebar/DocumentContent 对应 route 直调。"
[2026-04-17T15:58:30Z] [SESSION-3] Completed [task-009] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T15:58:30Z] [SESSION-3] CHECKPOINT [task-010] step=1/1 "已让 CustomSideMenu 的 pageReference 删除改走 shared tree command client。"
[2026-04-17T15:58:30Z] [SESSION-3] Completed [task-010] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T15:58:30Z] [SESSION-3] Starting [task-011] 执行 Stage P1-3:为 embed 定义独立 Rust page-tree command,并让 Sidebar/DocumentContent 改走 shared embed client (base=aa6ee384)
[2026-04-17T16:15:19Z] [SESSION-3] CHECKPOINT [task-011] step=1/1 "已把 embed 收口为独立 documents.embed 命令,并补齐 TS/Rust 映射与测试。"
[2026-04-17T16:15:19Z] [SESSION-3] Completed [task-011] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:15:19Z] [SESSION-3] Starting [task-012] 执行 Stage P1-4:把 copy-tree 收口到 shared tree command client,移除 Sidebar 中文档复制的直调 route (base=aa6ee384)
[2026-04-17T16:15:19Z] [SESSION-3] CHECKPOINT [task-012] step=1/1 "已确认 copy-tree 继续走 shared tree command client 与 Rust bridge command 主链,Sidebar 不再直调 route。"
[2026-04-17T16:15:19Z] [SESSION-3] Completed [task-012] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:15:19Z] [SESSION-3] Starting [task-013] 执行 Stage P2-1:把 targetParentId / sortOrder / subtree move legality 收口到 Rust/bridge 主链,并补回归测试 (base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-013] step=1/1 "已补 page-lifecycle-command-adapter 测试并收口 move/copy-tree 的 targetParentId、sortOrder 与 subtree move legality 归一化。"
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-013] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-014] 执行 Stage P2-2:统一 sidebar/page/file 的 kernel projection 契约,逐步移除前端本地 synthetic projection/fallback 真相 (base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-014] step=1/1 "已移除主路径 projection fallback/synthetic projection idsidebar/page/file 的 projection 契约改为只认上游 kernel 输出。"
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-014] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-015] 执行 Stage P2-3:清理 compat/fallback 与实验壳重复树拼装,确保主路径 consumer 只消费统一 projection (base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-015] step=1/1 "已确认 Sidebar/picker/host 主路径不再残留旧树拼装,sidebar-data 主路径不再本地合成 kernel projection。"
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-015] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-016] 执行 Stage P3-1:把 Rust Web SSE/WS 从 placeholder 升级为 workspace/subtree snapshot stream (base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-016] step=1/1 "已把 Rust Web SSE/WS 升级为 workspace/subtree snapshot stream,并复用统一 snapshot/stream support。"
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-016] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-017] 执行 Stage P3-2:接入 snapshot + delta + resync 协议与前端 tree stream consumer,逐步切 Sidebar/filetree/page subtree (base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-017] step=1/1 "已接入 useSidebarTreeStream 正式 consumer,并补齐 snapshot/delta/resync 协议与 reducer 测试。"
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-017] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-018] 执行 Stage P4-1:让 Rust Web 接住正式 SSR 页面壳与 server-first projection 分发,前端退回必要 islands (base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-018] step=1/1 "已验证 Rust Web kernel projection SSR 路由、前端 server data lint 与首页 smoke,主链仍不依赖 3104 可用。"
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-018] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-019] 执行 Stage QA-1:统一跑前端测试并修复必须修复的 bug,确保架构收口后的主链交互与关键回归稳定 (base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] CHECKPOINT [task-019] step=1/1 "已跑通前端全量测试并修复必须修复 bug:AiAgentPanel 过期断言与 tree stream 连接错误路由。"
[2026-04-17T16:35:06Z] [SESSION-3] Completed [task-019] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:35:06Z] [SESSION-3] Starting [task-020] 执行 Stage P4-2:回写长期架构文档与 harness 状态,勾选 design/tree-first-graph-convex-rust-long-term-architecture-v1.md 全部 checklist (base=aa6ee384)
[2026-04-17T16:36:54Z] [SESSION-3] CHECKPOINT [task-020] step=1/1 "已回写长期架构文档并清空全部 checklist,harness 状态已完成收尾。"
[2026-04-17T16:36:54Z] [SESSION-3] Completed [task-020] (commit skipped by repo rule; base=aa6ee384)
[2026-04-17T16:36:54Z] [SESSION-3] STATS tasks_total=20 completed=20 failed=0 pending=0 blocked=0 attempts_total=11 checkpoints=17
[2026-04-17T16:36:54Z] [SESSION-3] LOCK released
[2026-04-17T23:01:36Z] [SESSION-4] LOCK acquired (pid=codex)
[2026-04-17T23:01:36Z] [SESSION-4] INIT Harness session started for sidebar/page/filetree rust web rebuild batch task-021..task-041
[2026-04-17T23:01:36Z] [SESSION-4] Starting [task-021] 执行 TreeShell Phase A-1:产出共享 tree projection contract 文档,冻结 sidebar_tree/page_tree/file_tree 共用字段与差异字段 (base=f1c1bcf0)
[2026-04-17T23:18:26Z] [SESSION-4] CHECKPOINT [task-021] step=1/1 "已产出共享 tree projection contract 文档,并冻结 sidebar_tree/page_tree/file_tree 的共享字段、row_id 与 resource_meta 口径。"
[2026-04-17T23:18:26Z] [SESSION-4] Completed [task-021] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-17T23:18:26Z] [SESSION-4] Starting [task-022] 执行 TreeShell Phase A-2:落地前后端共享 projection 类型,并冻结 file_tree 扩展字段与 capability/icon/resource 语义 (base=f1c1bcf0)
[2026-04-17T23:18:30Z] [SESSION-4] CHECKPOINT [task-022] step=1/1 "已补齐前后端共享 projection 类型与 file_tree 扩展字段,并跑通 core-protocol 与 tree-protocol 目标测试。"
[2026-04-17T23:18:30Z] [SESSION-4] Completed [task-022] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-17T23:18:30Z] [SESSION-4] Starting [task-023] 执行 TreeShell Phase A-3:固定树域 command protocol 长期命名面,并给出 documents.* 到 tree.* 的兼容迁移文档 (base=f1c1bcf0)
[2026-04-17T23:18:34Z] [SESSION-4] CHECKPOINT [task-023] step=1/1 "已冻结 tree.* 长期命名面,并把 documents.* 到 tree.* 的兼容映射与 cutover 顺序写入设计文档。"
[2026-04-17T23:18:34Z] [SESSION-4] Completed [task-023] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-17T23:18:34Z] [SESSION-4] Starting [task-024] 执行 TreeShell Phase A-4:明确 projection 状态与本地 UI 状态边界,并形成 Rust/前端共享的状态约束文档 (base=f1c1bcf0)
[2026-04-17T23:18:38Z] [SESSION-4] CHECKPOINT [task-024] step=1/1 "已固定 projection truth 与本地 UI state 的边界,并锁定 expanded、selected、focus、dragging、drop target 不进入 projection。"
[2026-04-17T23:18:38Z] [SESSION-4] Completed [task-024] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-17T23:18:38Z] [SESSION-4] Starting [task-025] 执行 TreeShell Phase A-5:补齐 projection/command protocol fixtures 与 contract tests,锁死后续 Phase B-D 的协议基线 (base=f1c1bcf0)
[2026-04-17T23:18:42Z] [SESSION-4] CHECKPOINT [task-025] step=1/1 "已新增 projection/command protocol contract tests,并跑通前端 fixtures 与 mnote-web tree_projection_contract 验证。"
[2026-04-17T23:18:42Z] [SESSION-4] Completed [task-025] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-17T23:18:42Z] [SESSION-4] Starting [task-026] 执行 TreeShell Phase B-1:在 mnote-web 补正式 sidebar_tree/page_tree/file_tree projection route 族,退出“只靠 kernel sidebar 兼容入口”阶段 (base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-026] step=1/1 "已补齐 mnote-web 的 sidebar/page/file projection route 族,并重新验证 tree projection routes。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-026] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-027] step=1/1 "Rust 已直接输出 file_tree projection 的 document/index/asset 行语义,前端 rows 测试通过。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-027] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-028] step=1/1 "Rust file_tree mapper 已扩展到 book/pdf/index/iconHint/capabilities,并验证资源变体测试。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-028] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-029] step=1/1 "树域 command 主协议已切到 tree.*,同时保留 documents.* alias 与共享 client 回归通过。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-029] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-030] step=1/1 "树域 route 契约已补齐 trace/workspace/auth 边界,tree_route_contracts 测试通过。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-030] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-031] step=1/1 "Next 树域主路径已收口为 transport/compateslint 通过且 buildDocumentTree 主路径检查通过。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-031] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-032] step=1/1 "mnote-web 已接入 Leptos tree shell scaffoldtree_shell 模块与 loader/dispatcher/state 骨架测试通过。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-032] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-033] step=1/1 "page_tree renderer 与真实网页 smoke 已跑通;task091 创建/重命名/移动/导航链路通过。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-033] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-034] step=1/1 "selection/focus/keyboard/dragging 的局部 UI state 已落地并通过 Rust 测试与文档检查。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-034] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] CHECKPOINT [task-035] step=1/1 "正式 tree shell 的 filetree/picker 轻量模式已补齐,新增 move-embed-picker-dialog 前端测试并通过。"
[2026-04-18T00:38:02Z] [SESSION-4] Completed [task-035] (commit skipped by repo rule; base=f1c1bcf0)
[2026-04-18T00:38:02Z] [SESSION-4] Starting [task-036] 执行 TreeShell Phase C-5:去掉主路径对 iframe/postMessage 实验壳的依赖,把旧 tree shell 降回 compat/debug 用途 (base=f1c1bcf0)
[2026-04-18T01:37:10Z] [SESSION-5] LOCK acquired (pid=codex)
[2026-04-18T01:37:10Z] [SESSION-5] INIT Resumed harness for task-036..task-041 final integration and verification
[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-036] step=1/1 "已从 sidebar.tsx 主路径移除 iframe/postMessage 实验壳依赖,Sidebar 主树回到统一 surface。"
[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-036] (commit skipped by repo rule)
[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-037] step=1/1 "已完成 Sidebar 主树默认切流与 fallback 护栏,task101 网页 smoke 通过。"
[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-037] (commit skipped by repo rule)
[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-038] step=1/1 "已完成 page/filetree/picker 默认切流与高频回归,task102 与前端测试通过。"
[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-038] (commit skipped by repo rule)
[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-039] step=1/1 "已补性能报告,记录首包、首次可交互、切页延迟、大树展开延迟与 fallback。"
[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-039] (commit skipped by repo rule)
[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-040] step=1/1 "已统一 page/file/picker renderer surfacesidebar.tsx 不再直接耦合旧树组件名。"
[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-040] (commit skipped by repo rule)
[2026-04-18T01:37:10Z] [SESSION-5] CHECKPOINT [task-041] step=1/1 "已更新设计文档 checklist 并跑通最终 smoke、pnpm test、cargo test。"
[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-041] (commit skipped by repo rule)
[2026-04-18T01:37:10Z] [SESSION-5] STATS tasks_total=41 completed=41 failed=0 pending=0 blocked=0 attempts_total=32 checkpoints=38
[2026-04-18T01:37:10Z] [SESSION-5] LOCK released
[2026-04-18T01:48:47Z] [SESSION-6] LOCK acquired (pid=codex)
[2026-04-18T01:48:47Z] [SESSION-6] INIT Harness resumed for Kernel Phase 7 document read-view/pageSubtree batch task-042..task-044
[2026-04-18T01:48:47Z] [SESSION-6] INIT Environment health check: PASS
[2026-04-18T01:48:47Z] [SESSION-6] Starting [task-042] 执行 Kernel Phase 7-1:把文档页 pageSubtree 固化为服务端首包与 /api/documents/content 稳定响应契约,减少前端读链即时拼装 (base=f9a45e89)
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-042] step=1/1 "已把 pageSubtree 固化为 SSR 首包与 /api/documents/content 响应契约,并补齐共享归一化 helper 与单测。"
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-042] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-043] step=1/1 "已让 DocumentContent 优先消费服务端 pageSubtree 快照,仅在标题或内容偏离服务端快照时才本地重建。"
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-043] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-044] step=1/1 "已补 pageSubtree 响应单测,跑通前端全量测试、首页 smoke,并确认当前 3000 主链未因 3104/Convex 挂起。"
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-044] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T02:00:05Z] [SESSION-6] STATS tasks_total=44 completed=44 failed=0 pending=0 blocked=0 attempts_total=35 checkpoints=41
[2026-04-18T02:00:05Z] [SESSION-6] LOCK released
[2026-04-18T02:09:30Z] [MANUAL] BACKLOG appended task-045..task-069 with approved order: Kernel Phase 7 Rust pageSubtree/read_view cutover before AI-first Rust block editor baseline
[2026-04-18T02:09:30Z] [MANUAL] STATS tasks_total=69 completed=44 failed=0 pending=25 blocked=0 attempts_total=35 checkpoints=41
[2026-04-18T02:20:00Z] [SESSION-7] LOCK acquired (pid=codex)
[2026-04-18T02:20:00Z] [SESSION-7] INIT Harness resumed for Kernel Phase 7 Rust pageSubtree/read_view cutover and block editor baseline
[2026-04-18T02:20:00Z] [SESSION-7] Starting [task-045] 执行 Kernel Phase 7-4:把 pageSubtree/read_view 真正下沉到 bridge-runtime 与 mnote-web 输出边界,退出前端 projection 生成主路径 (base=f9a45e89)
[2026-04-18T02:45:00Z] [MANUAL] Reordered harness backlog: removed early 3000/3104/Convex guardrails from task-046/task-047 and BlockEditor Phase 3/5 validations; moved cross-runtime coordination to post-editor task-070/task-071 per latest user instruction.
[2026-04-18T03:13:39Z] [SESSION-8] LOCK acquired (pid=422517)
[2026-04-18T03:13:39Z] [SESSION-8] RECOVERY [task-045] action="resume-with-worktree" reason="检测到与 task-045 对应的文档页 pageSubtree/read_view 未提交改动,先保留并继续验证,不执行回滚。"
[2026-04-18T03:13:39Z] [SESSION-8] INIT Harness resumed for task-045 continuation and block editor backlog preparation
[2026-04-18T03:13:39Z] [SESSION-8] INIT Environment health check: PASS
[2026-04-18T03:33:58Z] [SESSION-8] CHECKPOINT [task-045] step=1/2 "已为 documents.content.get 补齐 Rust page_subtree 输出、mnote-web /api/documents/content route 与对应测试,cargo test -p bridge-runtime / mnote-web 通过。"
[2026-04-18T03:38:08Z] [SESSION-8] CHECKPOINT [task-045] step=2/2 "已切除前端 pageSubtree 主路径 fallback,文档页现在只消费 Rust 输出契约;pnpm vitest / pnpm test 通过。"
[2026-04-18T03:38:08Z] [SESSION-8] Completed [task-045] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T03:38:08Z] [SESSION-8] Starting [task-046] 执行 Kernel Phase 7-5:让 Next 文档页 SSR 与 /api/documents/content 优先消费 Rust 输出契约,移除前端 pageSubtree 主路径 fallback (base=f9a45e89)
[2026-04-18T03:38:08Z] [SESSION-8] CHECKPOINT [task-046] step=1/1 "已让 page-subtree-response、DocumentContent、DocumentReadView 退出 pageSubtree 主路径 fallbackSSR/API 只透传 Rust 输出契约。"
[2026-04-18T03:38:08Z] [SESSION-8] Completed [task-046] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T03:38:08Z] [SESSION-8] Starting [task-047] 执行 Kernel Phase 7-6:补文档页 read-view/outline/backlinks/AI context 回归,确认 read-path 收口后的读态契约稳定 (base=f9a45e89)
[2026-04-18T03:38:08Z] [SESSION-8] CHECKPOINT [task-047] step=1/1 "已跑通 pnpm test,确认文档读态、outline、AI context 与现有 backlinks 面板在 read-path 收口后仍稳定。"
[2026-04-18T03:38:08Z] [SESSION-8] Completed [task-047] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:16Z] [SESSION-9] LOCK acquired (pid=433880 reused-shell)
[2026-04-18T05:32:16Z] [SESSION-9] INIT Resumed harness for BlockEditor final verification, document-shell cutover QA, and baseline checklist sync
[2026-04-18T05:32:17Z] [SESSION-9] CHECKPOINT [task-058] step=1/1 "已补纯 Rust 回归测试、CLI editor 入口,并沉淀 mnote-next 交互样例集;cargo test -p mnote-editor-core 与 cargo test -p mnote-cli 通过。"
[2026-04-18T05:32:17Z] [SESSION-9] Completed [task-058] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:18Z] [SESSION-9] CHECKPOINT [task-059] step=1/1 "已在 mnote-web /document 落地最小 Rust 文档编辑壳,文档页通过 runtime flag 保持双路径切换;cargo test -p mnote-web 与 eslint 通过。"
[2026-04-18T05:32:18Z] [SESSION-9] Completed [task-059] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:19Z] [SESSION-9] CHECKPOINT [task-060] step=1/1 "已固定 block list / focus / selection / hover 壳策略,当前采用 Rust shell + 最小 DOM 交互语义,kode 保持为块内输入器参考层;cargo test -p mnote-web block_editor_shell 通过。"
[2026-04-18T05:32:19Z] [SESSION-9] Completed [task-060] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:20Z] [SESSION-9] CHECKPOINT [task-061] step=1/1 "已落地 slash 菜单、heading 折叠、有限缩进、[[page]] / ((block)) 最小引用交互;cargo test -p mnote-web editor_interactions 与 pnpm test 通过。"
[2026-04-18T05:32:20Z] [SESSION-9] Completed [task-061] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:21Z] [SESSION-9] CHECKPOINT [task-062] step=1/1 "已补浏览器回归链,默认新壳首屏不依赖旧 BlockNote/Yjs/commentstask103 与 pnpm test 通过。"
[2026-04-18T05:32:21Z] [SESSION-9] Completed [task-062] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:22Z] [SESSION-9] CHECKPOINT [task-063] step=1/1 "已冻结 AI/CLI 共用的 Rust editor command tool contract,并明确 AI 不再模拟 DOM/UI。"
[2026-04-18T05:32:22Z] [SESSION-9] Completed [task-063] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:23Z] [SESSION-9] CHECKPOINT [task-064] step=1/1 "已打通纯文本导入、AI 结构化成块与按目标重写文档链路,并补齐审计与 change reportai_pipeline 测试通过。"
[2026-04-18T05:32:23Z] [SESSION-9] Completed [task-064] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:24Z] [SESSION-9] CHECKPOINT [task-065] step=1/1 "已补 ai_regression_* 回归样例,覆盖会议记录转待办、长段落提炼标题、页面重排;cargo test -p mnote-cli ai_regression 与 pnpm test 通过。"
[2026-04-18T05:32:24Z] [SESSION-9] Completed [task-065] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:25Z] [SESSION-9] CHECKPOINT [task-066] step=1/1 "已完成 BlockNote 入口、依赖与内容格式迁移策略盘点,并保留 compat/debug 方案文档。"
[2026-04-18T05:32:25Z] [SESSION-9] Completed [task-066] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:26Z] [SESSION-9] CHECKPOINT [task-067] step=1/1 "已将主文档页默认编辑器切到 mnote-web 新文档壳,同时保留 ?editor=compat 回退;task103、pnpm test、eslint 通过。"
[2026-04-18T05:32:26Z] [SESSION-9] Completed [task-067] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:27Z] [SESSION-9] CHECKPOINT [task-069] step=1/1 "已回写主设计文档 checklist 与最终采用矩阵,并完成 task103、pnpm test、cargo test 全工作区收口。"
[2026-04-18T05:32:27Z] [SESSION-9] Completed [task-069] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T05:32:28Z] [SESSION-9] CHECKPOINT [task-068] step=1/2 "主编辑器默认切换与最终 QA 已完成;剩余 BlockNote/Yjs/Mantine 依赖清理保留在 post-baseline cleanuptask-068)。"
[2026-04-18T05:32:29Z] [SESSION-9] STATS tasks_total=71 completed=68 failed=0 pending=3 blocked=0 attempts_total=66 checkpoints=67
[2026-04-18T05:32:29Z] [SESSION-9] LOCK released
[2026-04-18T05:54:29Z] [MANUAL] CORRECTION Reframed task-059..062/task-067/task-069 as document shell prototype milestones instead of completed human editing runtime; kept current cutover for testing only.
[2026-04-18T05:54:29Z] [MANUAL] BACKLOG appended task-072..task-078 for human editing runtime: runtime contract, implementation path, real text input/IME/selection, editing transactions, slash/reference on true runtime, save-chain regression, and final runtime QA before cleanup/coordinator tasks.
[2026-04-18T05:54:29Z] [MANUAL] STATS tasks_total=78 completed=68 failed=0 pending=10 blocked=0 attempts_total=66 checkpoints=67
[2026-04-18T05:59:45Z] [SESSION-10] LOCK acquired (pid=codex)
[2026-04-18T05:59:45Z] [SESSION-10] INIT Harness resumed for human editing runtime task-072..task-078
[2026-04-18T05:59:45Z] [SESSION-10] Starting [task-072] 执行 BlockEditor Runtime 3R-1:基于 Wolai/Tiptap/当前 compat 路径冻结真人编辑 runtime 契约,明确 prototype shell 与 human editing runtime 的边界 (base=f9a45e89)
[2026-04-18T06:50:32Z] [SESSION-10] CHECKPOINT [task-072] step=1/1 "已在设计文档冻结真人编辑 runtime 契约,明确 prototype shell 与 human editing runtime 的边界。"
[2026-04-18T06:50:33Z] [SESSION-10] Completed [task-072] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T06:50:33Z] [SESSION-10] CHECKPOINT [task-073] step=1/1 "已固定实现路径:mnote-web 直接消费 documents.meta/content,真人输入宿主采用 textarea + Rust editor corekode/leptos-tiptap/compat 保留为参考与回退口径。"
[2026-04-18T06:50:34Z] [SESSION-10] Completed [task-073] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T06:50:34Z] [SESSION-10] CHECKPOINT [task-074] step=1/1 "已接入真实块内输入 runtime,补齐 focus/selection、beforeinput、input、IME/composition 与自动保存,并跑通 task104。"
[2026-04-18T06:50:35Z] [SESSION-10] Completed [task-074] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T06:50:35Z] [SESSION-10] CHECKPOINT [task-075] step=1/1 "已打通 split/merge/indent/outdent/toggle heading collapse 到 Rust editor core transform,并跑通 task105。"
[2026-04-18T06:50:36Z] [SESSION-10] Completed [task-075] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T06:50:36Z] [SESSION-10] CHECKPOINT [task-076] step=1/1 "已把 slash 菜单、[[page]] 与 ((block)) 引用插入接到真实可输入 runtime,并跑通 task106。"
[2026-04-18T06:50:37Z] [SESSION-10] Completed [task-076] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T06:50:37Z] [SESSION-10] CHECKPOINT [task-077] step=1/1 "已为 3104 文档读写加 Next API 兼容代理,宿主页改为 cookie 准备失败不阻断加载,并跑通 pnpm test + task107。"
[2026-04-18T06:50:38Z] [SESSION-10] Completed [task-077] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T06:50:38Z] [SESSION-10] CHECKPOINT [task-078] step=1/1 "已完成 Final QApnpm test、cargo test、task104/105/106/107 全部通过,真人编辑 runtime 达到最小可写闭环。"
[2026-04-18T06:50:39Z] [SESSION-10] Completed [task-078] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T06:50:39Z] [SESSION-10] STATS tasks_total=78 completed=75 failed=0 pending=3 blocked=0 attempts_total=73 checkpoints=74
[2026-04-18T12:00:00Z] [MANUAL] CORRECTION Applied editor-baseline-reset-v2: reverted default document page away from mnote-web runtime shell, kept runtime route only as explicit debug/prototype, and rewound task-067/task-069/task-072..078 back to pending.
[2026-04-18T12:00:00Z] [MANUAL] CHECKPOINT task-067/task-069 Runtime shell no longer counts as default editor completion; task103 now verifies product-page default path plus explicit /document-debug diagnostics.
[2026-04-18T12:00:00Z] [MANUAL] STATS tasks_total=78 completed=67 failed=0 pending=11 blocked=0 attempts_total=65 checkpoints=75
[2026-04-18T17:07:16Z] [SESSION-11] LOCK acquired (pid=659762)
[2026-04-18T17:07:16Z] [SESSION-11] INIT Harness resumed for task-067..task-080 Tiptap/Leptos/Rust editor migration
[2026-04-18T17:07:16Z] [SESSION-11] INIT Environment health check: PASS
[2026-04-18T17:07:16Z] [SESSION-11] Starting [task-067] 执行 BlockEditor P0-0:冻结新的 Tiptap/Leptos/Rust 迁移基线,确认官方 notion-like 模板只作为体验 benchmarkmnote-web runtime shell 继续只保留 debug/prototype 边界 (base=f9a45e89)
2026-04-18T17:20:02Z [SESSION-12] LOCK acquired (pid=729217)
2026-04-18T17:20:02Z [SESSION-12] INIT Resumed harness after stale lock recovery
[2026-04-18T17:51:55Z] [SESSION-12] RECOVERY [task-067] action="complete-from-verified-state" reason="文档与代码基线已核对完成,补写状态。"
[2026-04-18T17:51:55Z] [SESSION-12] CHECKPOINT [task-068] step=1/1 "task_list/task_item Rust 暴露、运行时注册与 8123 网页 schema 验证已完成。"
[2026-04-18T17:51:55Z] [SESSION-12] Completed [task-067] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T17:51:55Z] [SESSION-12] Completed [task-068] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T17:51:55Z] [SESSION-12] Completed [task-069] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T17:51:55Z] [SESSION-12] Completed [task-070] (commit skipped by repo rule; base=f9a45e89)
[2026-04-18T17:51:55Z] [SESSION-12] Completed [task-071] (commit skipped by repo rule; base=f9a45e89)
[2026-04-19T15:01:07Z] [SESSION-0] RESET Cleared historical harness backlog and progress; previous tree-shell, runtime-shell, and old editor migration batches no longer represent the current plan.
[2026-04-19T15:01:07Z] [SESSION-0] INIT Initialized new harness batch from /mnt/Data1T/mnote/design/05-editor-mainline/process/5-3-tiptap-leptos-rust-migration-checklist-v1.md with focus on P0.5 main-editor cutover.
[2026-04-19T15:01:07Z] [SESSION-0] INIT Current base commit a0edc9d1; harness scope is limited to main editor integration, Rust truth boundary, minimal schema/command mapping, and acceptance for the new Tiptap/Leptos editor path.
[2026-04-19T15:01:07Z] [SESSION-0] STATS tasks_total=10 completed=0 failed=0 pending=10 blocked=0 attempts_total=0 checkpoints=0
[2026-04-19T15:25:40Z] [SESSION-1] LOCK acquired (pid=3267717)
[2026-04-19T15:25:40Z] [SESSION-1] INIT Session start: base=a0edc9d1, task-001 set to in_progress
[2026-04-19T15:29:25Z] [SESSION-1] CHECKPOINT [task-001] step=1/1 "已冻结真实文档页主链、DocumentContent 接入点、切流 flag 归属,以及 runtime shell 仅保留 debug/prototype 的边界。"
[2026-04-19T15:29:25Z] [SESSION-1] Completed [task-001] (commit skipped by repo rule; base=a0edc9d1)
[2026-04-19T15:29:25Z] [SESSION-1] Starting [task-002] 执行 P0.5-2:把 leptos-tiptap host 接入真实文档页壳与真实路由,退出 localhost:8123 作为唯一验证入口的状态 (base=a0edc9d1)
[2026-04-19T16:12:53Z] [SESSION-1] CHECKPOINT [task-002] step=1/1 "已在真实 /documents/[id] 页面引入 editor host 切换层,新增 mnote-web embedded surface 与 main editor smoke,成功产出真实页面截图。"
[2026-04-19T16:12:53Z] [SESSION-1] Completed [task-002] (commit skipped by repo rule; base=a0edc9d1)
[2026-04-19T16:12:53Z] [SESSION-1] Starting [task-003] 执行 P0.5-3:引入稳定 block_id 策略,明确 Rust EditorBlock.block_id 为真相,Tiptap UniqueID 仅作 runtime 辅助 (base=a0edc9d1)
[2026-04-19T17:27:37Z] [MANUAL] RESET 用户已在 git 中撤销上一版 task-002 结果;该结果被判定为偏向 debug/runtime shell,不符合“8123 真实体验进入主编辑器”的目标。现将 task-002 重新置为 in_progresstask-003 及后续任务回退为 pending。
[2026-04-19T18:24:24Z] [SESSION-2] LOCK acquired (pid=1205413)
[2026-04-19T18:24:24Z] [SESSION-2] INIT Session restart: task-002 rerun after git rollback; bridge strategy switched from public dist embedding to real 8123 runtime + embed contract.
[2026-04-19T18:24:24Z] [SESSION-2] CHECKPOINT [task-002] step=1/1 "已在真实 /documents/[id] 页面接入真实 8123 runtime,去掉 bridge 假壳,改为 spike 主动上报 ready/height,并让 smoke 自动拉起 8123。"
[2026-04-19T18:24:24Z] [SESSION-2] Completed [task-002] (commit skipped by repo rule; screenshot=/mnt/Data1T/mnote/test/results/main-editor/block-editor-main-editor-smoke/2026-04-19T18-24-21-028Z/success-final.png)
[2026-04-19T18:24:24Z] [SESSION-2] STATS tasks_total=10 completed=2 failed=0 pending=8 blocked=0 attempts_total=2 checkpoints=2
+184 -2202
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+532 -7
View File
@@ -4,11 +4,14 @@ use adapter_onlyoffice::{
OnlyOfficeProxyPreparationInput, OnlyOfficeSessionResolveInput,
};
use core_domain::Timestamp;
use core_protocol::editor::{EditorBlockDocumentTiptapBridge, TiptapNode};
use core_protocol::{
default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, CommandEnvelope,
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
DocumentReadStats, DocumentReadSubtree, EmbedBlock, GetBlock, GetBridgeCommand,
default_tool_registry, invocation_kind_label, tool_effect_label, ActorPayload, BlockProps,
CommandEnvelope, ContentNode, ContentNodePayload, DocumentContentResult,
DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta,
DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats,
DocumentReadSubtree, EditorBlock, EditorBlockDocument, EditorBlockType, EditorCommand,
EditorInsertBlockAfter, EditorReplaceBlock, EmbedBlock, GetBlock, GetBridgeCommand,
GetBridgeRequest, GetBridgeTrace, GetMindmap, InvocationKind, KernelAttachEdge,
KernelAuditStamp, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
@@ -486,7 +489,12 @@ struct DocumentSaveCommandPayload {
document_id: String,
workspace_id: Option<String>,
revision: Option<u64>,
#[serde(default)]
editor_document: Option<Value>,
#[serde(default)]
content: Value,
#[serde(default)]
tiptap_document: Option<Value>,
conflict_detection_key: Option<String>,
}
@@ -673,6 +681,7 @@ struct RuntimeInsertSpec {
struct InsertBlocksResult {
inserted: Vec<String>,
blocks: Vec<Value>,
editor_commands: Vec<EditorCommand>,
}
#[derive(Debug, Serialize, PartialEq)]
@@ -1567,10 +1576,17 @@ fn execute_tool_result(
let after_block_id = read_optional_string_field(&args, "afterBlockId");
let before_block_id = read_optional_string_field(&args, "beforeBlockId");
let result = apply_insert_blocks(blocks, after_block_id, before_block_id, specs)?;
let editor_command_source = if result.editor_commands.is_empty() {
"legacy_snapshot_fallback"
} else {
"rust_editor_command"
};
Ok(json!({
"ok": true,
"source": infer_tool_source(&data),
"inserted": result.inserted,
"editorCommandSource": editor_command_source,
"editorCommands": result.editor_commands,
"data": result.blocks,
}))
}
@@ -1580,12 +1596,20 @@ fn execute_tool_result(
let text = read_required_string_field(&args, "text")?;
let mode =
read_optional_string_field(&args, "mode").unwrap_or_else(|| "replace".into());
let editor_commands = build_replace_editor_commands(&blocks, &block_id, &text, &mode);
let result = apply_replace_range(blocks, &block_id, &text, &mode)?;
let editor_command_source = if editor_commands.is_empty() {
"legacy_snapshot_fallback"
} else {
"rust_editor_command"
};
Ok(json!({
"ok": true,
"source": infer_tool_source(&data),
"blockId": block_id,
"mode": mode,
"editorCommandSource": editor_command_source,
"editorCommands": editor_commands,
"data": result,
}))
}
@@ -3257,6 +3281,13 @@ fn apply_insert_blocks(
.iter()
.map(build_block_from_spec)
.collect::<Vec<Value>>();
let editor_commands = build_insert_editor_commands(
&blocks,
after_block_id.as_deref(),
before_block_id.as_deref(),
&created,
&specs,
);
let inserted = created
.iter()
.filter_map(|block| read_string_value(block, "id"))
@@ -3280,7 +3311,11 @@ fn apply_insert_blocks(
None => blocks.extend(created),
}
Ok(InsertBlocksResult { inserted, blocks })
Ok(InsertBlocksResult {
inserted,
blocks,
editor_commands,
})
}
fn insert_blocks_at_target(
@@ -3328,6 +3363,146 @@ fn build_block_from_spec(spec: &RuntimeInsertSpec) -> Value {
})
}
fn build_text_content_nodes(text: &str) -> Vec<ContentNode> {
if text.trim().is_empty() {
return vec![];
}
vec![ContentNode {
payload: ContentNodePayload::Text {
text: text.to_string(),
marks: vec![],
},
attrs: BTreeMap::new(),
}]
}
fn editor_block_type_from_spec(spec: &RuntimeInsertSpec) -> EditorBlockType {
match spec.block_type.as_str() {
"heading" => EditorBlockType::Heading,
_ => EditorBlockType::Paragraph,
}
}
fn build_editor_block_from_spec(spec: &RuntimeInsertSpec, block_id: &str) -> EditorBlock {
let mut props = BlockProps::default();
if matches!(editor_block_type_from_spec(spec), EditorBlockType::Heading) {
props.heading_level = Some(spec.level.clamp(1, 5) as u8);
}
EditorBlock {
block_id: block_id.to_string(),
block_type: editor_block_type_from_spec(spec),
props,
content_nodes: build_text_content_nodes(&spec.text),
child_block_ids: vec![],
}
}
fn collect_block_order(blocks: &[Value], order: &mut Vec<String>) {
for block in blocks {
if let Some(block_id) = read_string_value(block, "id") {
order.push(block_id);
}
if let Some(children) = block
.as_object()
.and_then(|map| map.get("children"))
.and_then(Value::as_array)
{
collect_block_order(children, order);
}
}
}
fn resolve_insert_anchor(
blocks: &[Value],
after_block_id: Option<&str>,
before_block_id: Option<&str>,
) -> Option<String> {
let mut order = Vec::new();
collect_block_order(blocks, &mut order);
if let Some(after_block_id) = after_block_id {
if order.iter().any(|item| item == after_block_id) {
return Some(after_block_id.to_string());
}
return None;
}
if let Some(before_block_id) = before_block_id {
let index = order.iter().position(|item| item == before_block_id)?;
if index == 0 {
return None;
}
return order.get(index - 1).cloned();
}
order.last().cloned()
}
fn build_insert_editor_commands(
blocks: &[Value],
after_block_id: Option<&str>,
before_block_id: Option<&str>,
created: &[Value],
specs: &[RuntimeInsertSpec],
) -> Vec<EditorCommand> {
let mut anchor = resolve_insert_anchor(blocks, after_block_id, before_block_id);
if anchor.is_none() {
return vec![];
}
let mut commands = Vec::new();
for (created_block, spec) in created.iter().zip(specs.iter()) {
let Some(block_id) = read_string_value(created_block, "id") else {
continue;
};
let Some(after_block_id) = anchor.clone() else {
break;
};
commands.push(EditorCommand::InsertBlockAfter(EditorInsertBlockAfter {
after_block_id,
block: build_editor_block_from_spec(spec, &block_id),
}));
anchor = Some(block_id);
}
commands
}
fn find_block_text(blocks: &[Value], block_id: &str) -> Option<String> {
for block in blocks {
if read_string_value(block, "id").as_deref() == Some(block_id) {
return Some(extract_inline_text(block));
}
if let Some(children) = block
.as_object()
.and_then(|map| map.get("children"))
.and_then(Value::as_array)
{
if let Some(found) = find_block_text(children, block_id) {
return Some(found);
}
}
}
None
}
fn build_replace_editor_commands(
blocks: &[Value],
block_id: &str,
text: &str,
mode: &str,
) -> Vec<EditorCommand> {
let Some(previous) = find_block_text(blocks, block_id) else {
return vec![];
};
let next_text = match mode {
"append" => format!("{previous}{text}"),
"prepend" => format!("{text}{previous}"),
_ => text.to_string(),
};
vec![EditorCommand::ReplaceBlock(EditorReplaceBlock {
block_id: block_id.to_string(),
block_type: None,
props: None,
content_nodes: Some(build_text_content_nodes(&next_text)),
})]
}
fn apply_replace_range(
mut blocks: Vec<Value>,
block_id: &str,
@@ -5335,6 +5510,8 @@ fn execute_command(
}
"documents.save" => {
let payload: DocumentSaveCommandPayload = parse_payload(command_wire.payload.clone())?;
let editor_document = normalize_save_editor_document(&payload)?;
let canonical_content = legacy_content_from_editor_document(&editor_document);
let command = CommandEnvelope {
name: "documents.save".into(),
command_id: command_wire.command_id.clone(),
@@ -5346,7 +5523,7 @@ fn execute_command(
page_id: payload.document_id.clone(),
workspace_id: payload.workspace_id.clone(),
revision: payload.revision,
content_json: serde_json::to_string(&payload.content).map_err(|error| {
content_json: serde_json::to_string(&canonical_content).map_err(|error| {
BridgeError::validation(format!(
"documents.save content 序列化失败: {error}"
))
@@ -5371,7 +5548,9 @@ fn execute_command(
payload_json: request.payload_json,
args_json: json!({
"id": payload.document_id,
"content": payload.content,
"content": canonical_content,
"editorDocument": editor_document,
"tiptapDocument": payload.tiptap_document,
"expectedRevision": payload.revision,
"conflictDetectionKey": payload.conflict_detection_key,
}),
@@ -5919,6 +6098,191 @@ where
})
}
fn normalize_editor_block_type_for_save(raw_type: &str) -> EditorBlockType {
match raw_type {
"heading" => EditorBlockType::Heading,
"bullet_list_item" | "bullet_list" | "bullet-list" => EditorBlockType::BulletListItem,
"numbered_list_item" | "ordered_list" | "ordered-list" => EditorBlockType::NumberedListItem,
"todo" | "task" => EditorBlockType::Todo,
"quote" | "blockquote" => EditorBlockType::Quote,
"code" | "code_block" | "code-block" => EditorBlockType::CodeBlock,
"page_reference" => EditorBlockType::PageReference,
"block_reference" => EditorBlockType::BlockReference,
_ => EditorBlockType::Paragraph,
}
}
fn normalize_editor_block_from_legacy(block: &Value, index: usize) -> EditorBlock {
let block_id = read_trimmed_string_field(block, &["blockId", "id"])
.unwrap_or_else(|| format!("block-{}", index + 1));
let raw_type = read_trimmed_string_field(block, &["blockType", "type"])
.unwrap_or_else(|| "paragraph".into())
.to_lowercase();
let mut props = BlockProps::default();
if matches!(
normalize_editor_block_type_for_save(&raw_type),
EditorBlockType::Heading
) {
props.heading_level = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("level").or_else(|| map.get("headingLevel")))
.and_then(Value::as_u64)
.and_then(|value| u8::try_from(value).ok())
.map(|value| value.clamp(1, 6));
}
if matches!(
normalize_editor_block_type_for_save(&raw_type),
EditorBlockType::Todo
) {
props.checked = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("checked"))
.and_then(Value::as_bool);
}
if matches!(
normalize_editor_block_type_for_save(&raw_type),
EditorBlockType::CodeBlock
) {
props.language = block
.get("props")
.and_then(Value::as_object)
.and_then(|map| map.get("language"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
}
let text = read_trimmed_string_field(block, &["content"])
.filter(|value| !value.is_empty())
.unwrap_or_else(|| extract_inline_text(block));
EditorBlock {
block_id,
block_type: normalize_editor_block_type_for_save(&raw_type),
props,
content_nodes: build_text_content_nodes(&text),
child_block_ids: vec![],
}
}
fn editor_document_from_legacy_content(document_id: &str, content: &Value) -> EditorBlockDocument {
let blocks = normalize_blocks_from_value(content)
.iter()
.enumerate()
.map(|(index, block)| normalize_editor_block_from_legacy(block, index))
.collect::<Vec<EditorBlock>>();
let root_block_ids = blocks
.iter()
.map(|block| block.block_id.clone())
.collect::<Vec<String>>();
EditorBlockDocument {
document_id: document_id.to_string(),
root_block_ids,
blocks,
}
}
fn normalize_save_editor_document(
payload: &DocumentSaveCommandPayload,
) -> Result<EditorBlockDocument, BridgeError> {
if let Some(editor_document) = payload.editor_document.clone() {
let mut parsed =
serde_json::from_value::<EditorBlockDocument>(editor_document).map_err(|error| {
BridgeError::validation(format!("documents.save editorDocument 非法: {error}"))
})?;
if parsed.document_id.trim().is_empty() {
parsed.document_id = payload.document_id.clone();
}
if parsed.root_block_ids.is_empty() {
parsed.root_block_ids = parsed
.blocks
.iter()
.map(|block| block.block_id.clone())
.collect();
}
return Ok(parsed);
}
if let Some(tiptap_document) = payload.tiptap_document.clone() {
let parsed = serde_json::from_value::<TiptapNode>(tiptap_document).map_err(|error| {
BridgeError::validation(format!("documents.save tiptapDocument 非法: {error}"))
})?;
return EditorBlockDocumentTiptapBridge::from_tiptap_doc(
payload.document_id.clone(),
&parsed,
)
.map_err(|error| {
BridgeError::validation(format!(
"documents.save tiptap -> editorDocument 失败: {error:?}"
))
});
}
Ok(editor_document_from_legacy_content(
&payload.document_id,
&payload.content,
))
}
fn legacy_props_from_editor_block(block: &EditorBlock) -> Option<Value> {
match block.block_type {
EditorBlockType::Heading => Some(json!({
"level": block.props.heading_level.unwrap_or(1),
})),
EditorBlockType::Todo => Some(json!({
"checked": block.props.checked.unwrap_or(false),
})),
EditorBlockType::CodeBlock => Some(json!({
"language": block.props.language,
})),
_ => None,
}
}
fn legacy_text_from_editor_block(block: &EditorBlock) -> String {
block
.content_nodes
.iter()
.filter_map(|node| match &node.payload {
ContentNodePayload::Text { text, .. } => Some(text.as_str()),
ContentNodePayload::HardBreak => Some("\n"),
ContentNodePayload::ReferenceToken { token } => token.label.as_deref(),
})
.collect::<String>()
}
fn legacy_content_from_editor_document(document: &EditorBlockDocument) -> Value {
let mut ordered = Vec::<&EditorBlock>::new();
let mut seen = std::collections::BTreeSet::<String>::new();
for root_block_id in &document.root_block_ids {
if let Some(block) = document
.blocks
.iter()
.find(|block| &block.block_id == root_block_id)
{
seen.insert(block.block_id.clone());
ordered.push(block);
}
}
for block in &document.blocks {
if seen.insert(block.block_id.clone()) {
ordered.push(block);
}
}
Value::Array(
ordered
.into_iter()
.map(|block| {
json!({
"id": block.block_id,
"type": block.block_type,
"props": legacy_props_from_editor_block(block),
"content": legacy_text_from_editor_block(block),
})
})
.collect(),
)
}
fn to_bridge_context(context: RuntimeBridgeContextWire) -> BridgeContext {
BridgeContext {
deployment_id: context.deployment_id,
@@ -6722,6 +7086,25 @@ mod tests {
"source": "client",
"blockId": "block_1",
"mode": "replace",
"editorCommandSource": "rust_editor_command",
"editorCommands": [
{
"kind": "replace_block",
"blockId": "block_1",
"blockType": null,
"props": null,
"contentNodes": [
{
"payload": {
"type": "text",
"text": "新的正文",
"marks": []
},
"attrs": {}
}
]
}
],
"data": [
{
"id": "block_1",
@@ -6734,6 +7117,148 @@ mod tests {
);
}
#[test]
fn doc_insert_blocks_tool_emits_editor_commands() {
let result = execute_runtime_query(RuntimeInput::Tool {
context: demo_context(),
tool: RuntimeToolInvocationWire {
tool: "doc_insert_blocks".into(),
kind: "command".into(),
mode: Some("result".into()),
args_json: json!({
"afterBlockId": "block_1",
"blocks": [
{
"type": "heading",
"level": 2,
"text": "Rust 命令插入标题"
}
]
}),
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: Some("block_1".into()),
}),
reason: Some("在块后插入标题".into()),
refs: vec!["task-054".into()],
},
data: Some(json!({
"source": "client",
"blocks": [
{
"id": "block_1",
"type": "paragraph",
"content": [{"type":"text","text":"旧内容"}],
"children": []
}
]
})),
})
.expect("tool result should build");
assert_eq!(result.get("ok"), Some(&json!(true)));
assert_eq!(
result.get("editorCommandSource"),
Some(&json!("rust_editor_command"))
);
assert_eq!(
result.pointer("/editorCommands/0/kind"),
Some(&json!("insert_block_after"))
);
assert_eq!(
result.pointer("/editorCommands/0/afterBlockId"),
Some(&json!("block_1"))
);
assert_eq!(
result.pointer("/editorCommands/0/block/blockType"),
Some(&json!("heading"))
);
assert_eq!(
result.pointer("/editorCommands/0/block/props/headingLevel"),
Some(&json!(2))
);
assert_eq!(
result.pointer("/editorCommands/0/block/contentNodes/0/payload/text"),
Some(&json!("Rust 命令插入标题"))
);
}
#[test]
fn documents_save_command_plan_normalizes_tiptap_into_rust_truth() {
let plan = execute_runtime_input(RuntimeInput::Command {
context: demo_context(),
command: RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: "cmd_save_1".into(),
idempotency_key: Some("idem_save".into()),
actor: RuntimeActorWire {
actor_type: "user".into(),
actor_id: "user_1".into(),
session_id: Some("sess_1".into()),
},
source: RuntimeSourceWire {
channel: "next-route".into(),
client: "wolai-frontend".into(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some("ws_1".into()),
page_id: Some("doc_1".into()),
block_id: None,
}),
payload: json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
"revision": 4,
"content": [],
"tiptapDocument": {
"type": "doc",
"content": [
{
"type": "paragraph",
"attrs": {
"blockId": "p_1"
},
"content": [
{
"type": "text",
"text": "来自 Rust tiptap 归一化"
}
]
}
]
},
"conflictDetectionKey": "doc_1:4"
}),
reason: Some("保存正文".into()),
refs: vec!["task-055".into()],
dry_run: false,
validate_only: false,
},
})
.expect("documents.save plan should build");
let RuntimeExecutionPlan::Command(plan) = plan else {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.args_json.get("id"), Some(&json!("doc_1")));
assert_eq!(
plan.args_json.pointer("/editorDocument/rootBlockIds/0"),
Some(&json!("p_1"))
);
assert_eq!(
plan.args_json.pointer("/editorDocument/blocks/0/blockType"),
Some(&json!("paragraph"))
);
assert_eq!(plan.args_json.pointer("/content/0/id"), Some(&json!("p_1")));
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("来自 Rust tiptap 归一化"))
);
}
#[test]
fn blocks_move_command_plan_maps_to_blocks_move() {
let plan = execute_runtime_input(RuntimeInput::Command {
@@ -1,6 +1,7 @@
pub mod command;
pub mod markdown;
pub mod model;
pub mod tiptap;
pub use command::{
EditorAttachReferenceToken, EditorCommand, EditorCommandCatalog, EditorCommandDescriptor,
@@ -17,6 +18,10 @@ pub use model::{
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
};
pub use tiptap::{
EditorBlockDocumentTiptapBridge, EditorBlockDocumentTiptapError, TiptapBlockType,
TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapNode, TiptapTaskItemAttrs,
};
#[cfg(test)]
mod tests {
@@ -0,0 +1,494 @@
use crate::editor::model::{
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
TextMark,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TiptapBlockType {
Paragraph,
Heading,
BulletList,
OrderedList,
TaskList,
ListItem,
TaskItem,
Blockquote,
CodeBlock,
Text,
HardBreak,
Doc,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TiptapHeadingAttrs {
pub level: u8,
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapTaskItemAttrs {
#[serde(default)]
pub checked: bool,
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapParagraphAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapListAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapListItemAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct TiptapBlockquoteAttrs {
#[serde(default)]
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TiptapCodeBlockAttrs {
pub language: Option<String>,
pub block_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TiptapMark {
pub r#type: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum TiptapNode {
Doc {
content: Vec<TiptapNode>,
},
Paragraph {
#[serde(default)]
attrs: TiptapParagraphAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
Heading {
attrs: TiptapHeadingAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
BulletList {
#[serde(default)]
attrs: TiptapListAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
OrderedList {
#[serde(default)]
attrs: TiptapListAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
TaskList {
#[serde(default)]
attrs: TiptapListAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
ListItem {
#[serde(default)]
attrs: TiptapListItemAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
TaskItem {
attrs: TiptapTaskItemAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
Blockquote {
#[serde(default)]
attrs: TiptapBlockquoteAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
CodeBlock {
attrs: TiptapCodeBlockAttrs,
#[serde(default)]
content: Vec<TiptapNode>,
},
Text {
text: String,
#[serde(default)]
marks: Vec<TiptapMark>,
},
HardBreak,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditorBlockDocumentTiptapError {
UnsupportedNode(&'static str),
InvalidDocument(&'static str),
}
pub struct EditorBlockDocumentTiptapBridge;
impl EditorBlockDocumentTiptapBridge {
pub fn to_tiptap_doc(
document: &EditorBlockDocument,
) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {
let mut content = Vec::new();
for block_id in &document.root_block_ids {
let block = document
.blocks
.iter()
.find(|block| &block.block_id == block_id)
.ok_or(EditorBlockDocumentTiptapError::InvalidDocument(
"根块不存在",
))?;
content.push(block_to_tiptap_node(block)?);
}
Ok(TiptapNode::Doc { content })
}
pub fn from_tiptap_doc(
document_id: impl Into<String>,
doc: &TiptapNode,
) -> Result<EditorBlockDocument, EditorBlockDocumentTiptapError> {
match doc {
TiptapNode::Doc { content } => {
let mut blocks = Vec::new();
let mut root_block_ids = Vec::new();
for (index, node) in content.iter().enumerate() {
let block = node_to_block(node, index)?;
root_block_ids.push(block.block_id.clone());
blocks.push(block);
}
Ok(EditorBlockDocument {
document_id: document_id.into(),
root_block_ids,
blocks,
})
}
_ => Err(EditorBlockDocumentTiptapError::InvalidDocument(
"顶层必须是 doc",
)),
}
}
}
fn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {
let content = text_nodes_to_tiptap(&block.content_nodes)?;
match block.block_type {
EditorBlockType::Paragraph => Ok(TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}),
EditorBlockType::Heading => Ok(TiptapNode::Heading {
attrs: TiptapHeadingAttrs {
level: block.props.heading_level.unwrap_or(1),
block_id: Some(block.block_id.clone()),
},
content,
}),
EditorBlockType::BulletListItem => Ok(TiptapNode::BulletList {
attrs: TiptapListAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::ListItem {
attrs: TiptapListItemAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}],
}),
EditorBlockType::NumberedListItem => Ok(TiptapNode::OrderedList {
attrs: TiptapListAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::ListItem {
attrs: TiptapListItemAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}],
}),
EditorBlockType::Todo => Ok(TiptapNode::TaskList {
attrs: TiptapListAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::TaskItem {
attrs: TiptapTaskItemAttrs {
checked: block.props.checked.unwrap_or(false),
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}],
}),
EditorBlockType::Quote => Ok(TiptapNode::Blockquote {
attrs: TiptapBlockquoteAttrs {
block_id: Some(block.block_id.clone()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some(block.block_id.clone()),
},
content,
}],
}),
EditorBlockType::CodeBlock => Ok(TiptapNode::CodeBlock {
attrs: TiptapCodeBlockAttrs {
language: block.props.language.clone(),
block_id: Some(block.block_id.clone()),
},
content,
}),
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"当前块型暂不支持导出到 Tiptap",
)),
}
}
fn node_to_block(
node: &TiptapNode,
index: usize,
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
let fallback_block_id = format!("block_{}", index + 1);
match node {
TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Paragraph,
props: BlockProps::default(),
content_nodes: text_nodes_from_tiptap(content)?,
child_block_ids: vec![],
}),
TiptapNode::Heading { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Heading,
props: BlockProps {
heading_level: Some(attrs.level),
..BlockProps::default()
},
content_nodes: text_nodes_from_tiptap(content)?,
child_block_ids: vec![],
}),
TiptapNode::BulletList { attrs, content } => list_node_to_block(
attrs.block_id.clone().unwrap_or(fallback_block_id),
EditorBlockType::BulletListItem,
content,
),
TiptapNode::OrderedList { attrs, content } => list_node_to_block(
attrs.block_id.clone().unwrap_or(fallback_block_id),
EditorBlockType::NumberedListItem,
content,
),
TiptapNode::TaskList { attrs, content } => {
list_task_node_to_block(attrs.block_id.clone().unwrap_or(fallback_block_id), content)
}
TiptapNode::Blockquote { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::Quote,
props: BlockProps::default(),
content_nodes: text_nodes_from_tiptap(extract_block_container_content(content)?)?,
child_block_ids: vec![],
}),
TiptapNode::CodeBlock { attrs, content } => Ok(EditorBlock {
block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),
block_type: EditorBlockType::CodeBlock,
props: BlockProps {
language: attrs.language.clone(),
..BlockProps::default()
},
content_nodes: text_nodes_from_tiptap(content)?,
child_block_ids: vec![],
}),
_ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"当前节点暂不支持导入为 EditorBlockDocument",
)),
}
}
fn list_node_to_block(
block_id: String,
block_type: EditorBlockType,
content: &[TiptapNode],
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
Ok(EditorBlock {
block_id,
block_type,
props: BlockProps::default(),
content_nodes: text_nodes_from_tiptap(extract_list_item_inline_content(content)?)?,
child_block_ids: vec![],
})
}
fn list_task_node_to_block(
block_id: String,
content: &[TiptapNode],
) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {
let Some(first) = content.first() else {
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
"任务列表节点缺少 task_item",
));
};
let (checked, item_content) = match first {
TiptapNode::TaskItem { attrs, content } => (
Some(attrs.checked),
extract_block_container_content(content)?,
),
_ => {
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
"任务列表节点首子节点类型不正确",
))
}
};
Ok(EditorBlock {
block_id,
block_type: EditorBlockType::Todo,
props: BlockProps {
checked,
..BlockProps::default()
},
content_nodes: text_nodes_from_tiptap(item_content)?,
child_block_ids: vec![],
})
}
fn extract_list_item_inline_content(
content: &[TiptapNode],
) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {
let Some(first) = content.first() else {
return Err(EditorBlockDocumentTiptapError::InvalidDocument(
"列表节点缺少 list_item/task_item",
));
};
match first {
TiptapNode::ListItem { content, .. } | TiptapNode::TaskItem { content, .. } => {
extract_block_container_content(content)
}
_ => Err(EditorBlockDocumentTiptapError::InvalidDocument(
"列表节点首子节点类型不正确",
)),
}
}
fn extract_block_container_content(
content: &[TiptapNode],
) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {
let Some(first) = content.first() else {
return Ok(content);
};
match first {
TiptapNode::Paragraph { content, .. } => Ok(content),
_ => Ok(content),
}
}
fn text_nodes_to_tiptap(
nodes: &[ContentNode],
) -> Result<Vec<TiptapNode>, EditorBlockDocumentTiptapError> {
let mut out = Vec::new();
for node in nodes {
match &node.payload {
ContentNodePayload::Text { text, marks } => out.push(TiptapNode::Text {
text: text.clone(),
marks: marks.iter().map(text_mark_to_tiptap).collect(),
}),
ContentNodePayload::HardBreak => out.push(TiptapNode::HardBreak),
ContentNodePayload::ReferenceToken { .. } => {
return Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"引用 token 暂不映射到 Tiptap 文本合同",
))
}
}
}
Ok(out)
}
fn text_nodes_from_tiptap(
nodes: &[TiptapNode],
) -> Result<Vec<ContentNode>, EditorBlockDocumentTiptapError> {
let mut out = Vec::new();
for node in nodes {
match node {
TiptapNode::Text { text, marks } => out.push(ContentNode {
payload: ContentNodePayload::Text {
text: text.clone(),
marks: marks.iter().filter_map(tiptap_mark_to_text_mark).collect(),
},
attrs: BTreeMap::new(),
}),
TiptapNode::HardBreak => out.push(ContentNode {
payload: ContentNodePayload::HardBreak,
attrs: BTreeMap::new(),
}),
_ => {
return Err(EditorBlockDocumentTiptapError::UnsupportedNode(
"当前只支持文本节点内容",
))
}
}
}
Ok(out)
}
fn text_mark_to_tiptap(mark: &TextMark) -> TiptapMark {
let r#type = match mark {
TextMark::Bold => "bold",
TextMark::Italic => "italic",
TextMark::Underline => "underline",
TextMark::Strike => "strike",
TextMark::Code => "code",
};
TiptapMark {
r#type: r#type.to_string(),
}
}
fn tiptap_mark_to_text_mark(mark: &TiptapMark) -> Option<TextMark> {
match mark.r#type.as_str() {
"bold" => Some(TextMark::Bold),
"italic" => Some(TextMark::Italic),
"underline" => Some(TextMark::Underline),
"strike" => Some(TextMark::Strike),
"code" => Some(TextMark::Code),
_ => None,
}
}
@@ -0,0 +1,216 @@
use core_protocol::editor::{
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument,
EditorBlockDocumentTiptapBridge, EditorBlockType, TextMark, TiptapCodeBlockAttrs,
TiptapHeadingAttrs, TiptapNode, TiptapParagraphAttrs, TiptapTaskItemAttrs,
};
use std::collections::BTreeMap;
#[test]
fn p05_blocks_round_trip_through_tiptap_doc() {
let document = EditorBlockDocument {
document_id: "doc_1".into(),
root_block_ids: vec![
"p1".into(),
"h1".into(),
"b1".into(),
"n1".into(),
"t1".into(),
"q1".into(),
"c1".into(),
],
blocks: vec![
EditorBlock {
block_id: "p1".into(),
block_type: EditorBlockType::Paragraph,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "段落".into(),
marks: vec![TextMark::Bold],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "h1".into(),
block_type: EditorBlockType::Heading,
props: BlockProps {
heading_level: Some(2),
..BlockProps::default()
},
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "标题".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "b1".into(),
block_type: EditorBlockType::BulletListItem,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "项目".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "n1".into(),
block_type: EditorBlockType::NumberedListItem,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "序号".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "t1".into(),
block_type: EditorBlockType::Todo,
props: BlockProps {
checked: Some(true),
..BlockProps::default()
},
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "待办".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "q1".into(),
block_type: EditorBlockType::Quote,
props: BlockProps::default(),
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "引用".into(),
marks: vec![],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
EditorBlock {
block_id: "c1".into(),
block_type: EditorBlockType::CodeBlock,
props: BlockProps {
language: Some("rust".into()),
..BlockProps::default()
},
content_nodes: vec![ContentNode {
payload: ContentNodePayload::Text {
text: "fn main() {}".into(),
marks: vec![TextMark::Code],
},
attrs: BTreeMap::new(),
}],
child_block_ids: vec![],
},
],
};
let doc =
EditorBlockDocumentTiptapBridge::to_tiptap_doc(&document).expect("export should work");
let round_trip = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_2", &doc)
.expect("import should work");
assert_eq!(
round_trip.root_block_ids,
vec!["p1", "h1", "b1", "n1", "t1", "q1", "c1"]
);
assert_eq!(round_trip.blocks[0].block_id, "p1");
assert_eq!(round_trip.blocks[0].content_nodes.len(), 1);
match &round_trip.blocks[0].content_nodes[0].payload {
ContentNodePayload::Text { marks, .. } => assert_eq!(marks, &vec![TextMark::Bold]),
other => panic!("unexpected payload: {other:?}"),
}
assert_eq!(round_trip.blocks[1].props.heading_level, Some(2));
assert_eq!(round_trip.blocks[4].props.checked, Some(true));
assert_eq!(round_trip.blocks[6].props.language.as_deref(), Some("rust"));
}
#[test]
fn tiptap_import_preserves_explicit_block_ids_and_marks() {
let doc = TiptapNode::Doc {
content: vec![
TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some("p-1".into()),
},
content: vec![TiptapNode::Text {
text: "Hello".into(),
marks: vec![core_protocol::editor::TiptapMark {
r#type: "italic".into(),
}],
}],
},
TiptapNode::Heading {
attrs: TiptapHeadingAttrs {
level: 3,
block_id: Some("h-1".into()),
},
content: vec![TiptapNode::Text {
text: "H".into(),
marks: vec![],
}],
},
TiptapNode::TaskList {
attrs: core_protocol::editor::TiptapListAttrs {
block_id: Some("t-1".into()),
},
content: vec![TiptapNode::TaskItem {
attrs: TiptapTaskItemAttrs {
checked: true,
block_id: Some("t-1".into()),
},
content: vec![TiptapNode::Paragraph {
attrs: TiptapParagraphAttrs {
block_id: Some("t-1".into()),
},
content: vec![TiptapNode::Text {
text: "todo".into(),
marks: vec![],
}],
}],
}],
},
TiptapNode::CodeBlock {
attrs: TiptapCodeBlockAttrs {
language: Some("ts".into()),
block_id: Some("c-1".into()),
},
content: vec![TiptapNode::Text {
text: "let x = 1;".into(),
marks: vec![core_protocol::editor::TiptapMark {
r#type: "code".into(),
}],
}],
},
],
};
let imported = EditorBlockDocumentTiptapBridge::from_tiptap_doc("doc_3", &doc).unwrap();
assert_eq!(imported.root_block_ids, vec!["p-1", "h-1", "t-1", "c-1"]);
assert_eq!(imported.blocks[0].block_id, "p-1");
assert_eq!(imported.blocks[1].props.heading_level, Some(3));
assert_eq!(imported.blocks[2].props.checked, Some(true));
assert_eq!(imported.blocks[3].props.language.as_deref(), Some("ts"));
match &imported.blocks[0].content_nodes[0].payload {
ContentNodePayload::Text { marks, .. } => assert_eq!(marks, &vec![TextMark::Italic]),
other => panic!("unexpected payload: {other:?}"),
}
}
@@ -39,7 +39,9 @@ pub struct DocumentSaveRequest {
pub workspace_id: Option<String>,
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub editor_document: Option<Value>,
pub content: Value,
pub tiptap_document: Option<Value>,
pub snapshot_captured_at: Option<String>,
pub block_count: Option<u64>,
}
@@ -346,7 +348,9 @@ async fn proxy_next_documents_save(
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"editorDocument": body.editor_document,
"content": body.content,
"tiptapDocument": body.tiptap_document,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
})),
@@ -472,7 +476,9 @@ pub async fn save(
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"editorDocument": body.editor_document,
"content": body.content,
"tiptapDocument": body.tiptap_document,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
}),
+1
View File
@@ -971,6 +971,7 @@ name = "mnote-leptos-tiptap-spike"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
"leptos",
"leptos-tiptap",
"leptos_dom",
+2 -1
View File
@@ -11,10 +11,11 @@ leptos = { version = "0.8.19", features = ["csr"] }
leptos_dom = "0.8.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
js-sys = "0.3.77"
wasm-bindgen = "0.2"
web-sys = { version = "0.3.77", features = ["DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MouseEvent", "Node", "Range", "Selection", "Storage", "Window"] }
[dependencies.leptos-tiptap]
path = "../../../design/blocknote/reference-code/leptos-tiptap"
path = "../../../design/05-editor-mainline/reference-code/leptos-tiptap"
default-features = false
features = ["component", "full"]
+118
View File
@@ -0,0 +1,118 @@
# 8123 leptos-tiptap runtime bridge
这是 `rust/spikes/leptos-tiptap-spike` 的宿主桥接说明,面向前端 host / QA。
## runtime 识别
宿主可用下面特征识别这套 runtime:
- `data-mnote-runtime="8123-leptos-tiptap-runtime"`
- `data-mnote-runtime-bridge="true"`
- `data-testid="mnote-leptos-tiptap-host"`
稳定锚点:
- root: `data-testid="mnote-leptos-tiptap-host"`
- stage: `#editor-stage` / `data-testid="mnote-leptos-tiptap-editor-stage"`
- editor root: `.editor-surface .ProseMirror` / `data-testid="mnote-leptos-tiptap-editor-root"`
- toolbar: `data-testid="mnote-leptos-tiptap-toolbar"`
- slash menu: `data-testid="mnote-leptos-tiptap-slash-menu"`
- handle: `data-testid="mnote-leptos-tiptap-handle"`
## 事件协议
所有事件都走同一 envelope
```json
{
"protocol": "mnote.leptos_tiptap.bridge.v1",
"runtime": "8123-leptos-tiptap-runtime",
"version": "1.0.0",
"source": "mnote:leptos-tiptap-spike",
"payload": {}
}
```
### `mnote:leptos-tiptap-spike:ready`
首次挂载完成时发送,告诉 host 这套 runtime 可以被嵌入。
payload 里至少包含:
- `runtime_name`
- `selectors`
- `supported_commands`
- `supports_embedded_mode`
### `mnote:leptos-tiptap-spike:state`
发送当前最小可观测状态。
payload 字段:
- `document_id`
- `title`
- `dirty_count`
- `selected_block_index`
- `editor_focused`
- `slash_open`
- `toolbar_open`
### `mnote:leptos-tiptap-spike:change`
内容变化时发送结构化文档快照。
payload 结构:
- `title`: 文档标题
- `content`: 当前文档 JSON
- `meta`: 附加运行态信息(如 dirty_count / selection / focus
### `mnote:leptos-tiptap-spike:save-request`
保存路径或 host 明确要求落盘时发送,payload 与 `change` 一致,但语义是“请宿主处理保存”。
### `mnote:leptos-tiptap-spike:command`
预留宿主到 runtime 的命令通道。当前 spike 只声明了最小命令集合,还没有把完整命令分发做成正式 host API。
## 选择器清单
建议 host/QA 只依赖这些稳定选择器:
- root: `[data-testid="mnote-leptos-tiptap-host"]`
- stage: `#editor-stage``[data-testid="mnote-leptos-tiptap-editor-stage"]`
- editor: `.editor-surface .ProseMirror``[data-testid="mnote-leptos-tiptap-editor-root"]`
- toolbar: `[data-testid="mnote-leptos-tiptap-toolbar"]`
- slash menu: `[data-testid="mnote-leptos-tiptap-slash-menu"]`
- handle: `[data-testid="mnote-leptos-tiptap-handle"]`
## 推荐嵌入方式
推荐把这套 runtime 作为 iframe 或独立容器挂载,然后由 host 监听 `document` 上的 `CustomEvent`
1. 先等待 `ready`
2.`ready.payload.selectors` 取稳定锚点。
3. 监听 `change` / `state` / `save-request`
4. 需要操作时再发 `command`
最小监听示例:
```ts
const onReady = (event: Event) => {
const detail = (event as CustomEvent).detail;
// detail.protocol / detail.payload.selectors
};
document.addEventListener('mnote:leptos-tiptap-spike:ready', onReady as EventListener);
document.addEventListener('mnote:leptos-tiptap-spike:change', onReady as EventListener);
document.addEventListener('mnote:leptos-tiptap-spike:state', onReady as EventListener);
document.addEventListener('mnote:leptos-tiptap-spike:save-request', onReady as EventListener);
```
## 已知限制
- 这仍是 8123 spike,不是正式主编辑器切流完成态。
- 目前桥接只保证稳定事件与稳定 selector,不保证 host 命令已经全量实现。
- 仍保留独立运行能力,host 不应依赖 debug 文案。
- 保存仍以 spike 本地草稿逻辑为主,真正主链保存合同还没迁移完。
+53 -20
View File
@@ -181,6 +181,36 @@ const SPIKE_STYLE: &str = r#"
padding: 28px 20px 72px;
}
.app-shell-embedded {
max-width: none;
margin: 0;
padding: 0;
}
.app-shell-embedded .hero-bar,
.app-shell-embedded .title-input,
.app-shell-embedded .editor-subcopy,
.app-shell-embedded .footer-strip,
.app-shell-embedded .debug-drawer,
.app-shell-embedded .editor-crumbs:first-child {
display: none;
}
.app-shell-embedded .editor-card {
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
.app-shell-embedded .editor-topbar {
padding: 8px 12px 0;
}
.app-shell-embedded .editor-stage {
padding-top: 0;
}
.hero-bar {
display: flex;
justify-content: space-between;
@@ -1209,10 +1239,7 @@ fn target_element(target: web_sys::EventTarget) -> Option<Element> {
})
}
fn event_target_matches_selector(
target: Option<web_sys::EventTarget>,
selector: &str,
) -> bool {
fn event_target_matches_selector(target: Option<web_sys::EventTarget>, selector: &str) -> bool {
target
.and_then(target_element)
.and_then(|element| element.closest(selector).ok().flatten())
@@ -2260,8 +2287,7 @@ fn App() -> impl IntoView {
let (text_selection_active, set_text_selection_active) = signal(false);
let (floating_toolbar_anchor, set_floating_toolbar_anchor) =
signal(None::<FloatingToolbarAnchor>);
let (locked_toolbar_anchor, set_locked_toolbar_anchor) =
signal(None::<FloatingToolbarAnchor>);
let (locked_toolbar_anchor, set_locked_toolbar_anchor) = signal(None::<FloatingToolbarAnchor>);
let (slash_open, set_slash_open) = signal(false);
let (slash_index, set_slash_index) = signal(0_usize);
let (turn_into_open, set_turn_into_open) = signal(false);
@@ -2596,6 +2622,10 @@ fn App() -> impl IntoView {
};
let selection_text = move || selection_summary(&selection_state.get());
let is_embedded = window()
.and_then(|win| win.location().search().ok())
.map(|search| search.contains("embedded=1"))
.unwrap_or(false);
let on_link_click = {
let editor = editor;
@@ -2633,7 +2663,7 @@ fn App() -> impl IntoView {
view! {
<style>{SPIKE_STYLE}</style>
<main class="app-shell">
<main class=move || if is_embedded { "app-shell app-shell-embedded" } else { "app-shell" }>
<section class="hero-bar">
<div class="hero-meta">
<span class="hero-tag">"P0 / Tiptap-like 主编辑器体验"</span>
@@ -2750,6 +2780,18 @@ fn App() -> impl IntoView {
set_hovered_block.set(None);
}
on:mouseover=move |event: MouseEvent| {
if block_menu_open.get_untracked() || dragging_block_index.get_untracked().is_some() {
return;
}
let Some(target) = event.target() else {
return;
};
if let Some(block) = hovered_block_from_target(target) {
set_editor_focused.set(true);
set_hovered_block.set(Some(block));
}
}
on:mouseleave=move |_| {
if !block_menu_open.get_untracked() && dragging_block_index.get_untracked().is_none() {
set_hovered_block.set(None);
@@ -3717,19 +3759,10 @@ fn App() -> impl IntoView {
}
fn initial_content() -> TiptapContent {
TiptapContent::html(
r#"
<h1>准备接管主编辑器体验</h1>
<p>这页现在先只在 <strong>8123</strong> 做 P0 体验收口:你可以输入 <code>/</code> 打开命令菜单,也可以直接试试选中文字后的浮动工具条。</p>
<p>目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。</p>
<ul>
<li>标题、段落、引用、代码块</li>
<li>无序列表 / 有序列表 / Todo 列表</li>
<li>分割线、行内样式、链接和高亮</li>
</ul>
<p>请把光标放到这里,然后按 <code>/</code>。</p>
"#,
)
TiptapContent::json(serde_json::json!({
"type": "doc",
"content": []
}))
}
fn main() {
View File
@@ -9,6 +9,7 @@ import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/do
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
import { normalizeEditorHostKind, type EditorHostKind } from "@/components/editor/editor-host-config";
interface DocumentPageProps {
params: Promise<{ id: string }>;
@@ -127,6 +128,9 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
const resolvedSearch = (await searchParams) ?? {};
const openTableIdRaw = resolvedSearch?.openTableId;
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
const editorHostRaw = resolvedSearch?.editorHost;
const editorHostKind: EditorHostKind =
typeof editorHostRaw === "string" ? normalizeEditorHostKind(editorHostRaw) : "blocknote";
if (isConvexEnabled()) {
const workspaceIdRaw = resolvedSearch?.workspaceId;
@@ -187,6 +191,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
initialOptions={initialOptions}
initialStats={initialStats}
openTableId={openTableId}
editorHostKind={editorHostKind}
readOnly={readOnly}
disableDownload={disableDownload}
disableCopy={disableCopy}
@@ -15,14 +15,18 @@ import {
export async function POST(request: Request) {
if (isConvexEnabled()) {
try {
const body = await request.json() as Partial<DocumentSavePayload> & { content: unknown };
const body = await request.json() as Partial<DocumentSavePayload> & { content?: unknown };
const normalizedDocumentId = assertDocumentId(body.documentId);
const payload = buildDocumentSavePayload({
documentId: normalizedDocumentId,
workspaceId: body.workspaceId,
revision: body.revision,
content: body.content as DocumentSavePayload["content"],
editorDocument: body.editorDocument,
content: body.content as DocumentSavePayload["content"] | undefined,
tiptapDocument: body.tiptapDocument,
conflictDetectionKey: body.conflictDetectionKey,
snapshotCapturedAt: body.snapshotCapturedAt,
blockCount: body.blockCount,
});
const normalizedWorkspaceId = payload.workspaceId;
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
@@ -0,0 +1,169 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type RuntimeManifest = {
bridgeRuntimePath: string | null;
extensionModulePaths: string[];
generatedRootPath: string | null;
entryScriptPath: string | null;
wasmPath: string | null;
};
const DIST_ROOT = path.resolve(process.cwd(), "..", "rust", "spikes", "leptos-tiptap-spike", "dist");
const ENTRY_SCRIPT_PATTERN = /^mnote-leptos-tiptap-spike-.*\.js$/;
const WASM_PATTERN = /^mnote-leptos-tiptap-spike-.*_bg\.wasm$/;
const EXTENSION_MODULE_PATTERN = /^tiptap_[a-z0-9_]+\.js$/;
function toPosixPath(value: string): string {
return value.split(path.sep).join("/");
}
function sanitizeRelativePath(asset: string[]): string | null {
if (!Array.isArray(asset) || asset.length === 0) {
return null;
}
const decoded = asset.map((segment) => decodeURIComponent(segment));
const joined = decoded.join("/");
if (!joined || joined.includes("\0")) {
return null;
}
const normalized = path.posix.normalize(joined);
if (normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) {
return null;
}
return normalized;
}
async function walkFiles(rootDir: string): Promise<string[]> {
const output: string[] = [];
const queue: string[] = [rootDir];
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
queue.push(absolute);
continue;
}
if (!entry.isFile()) {
continue;
}
const relative = path.relative(rootDir, absolute);
output.push(toPosixPath(relative));
}
}
return output.sort((a, b) => a.localeCompare(b));
}
async function buildRuntimeManifest(): Promise<RuntimeManifest> {
const files = await walkFiles(DIST_ROOT);
const bridgeRuntimePath = files.find((item) => item.endsWith("/bridge_runtime.js") || item === "bridge_runtime.js") ?? null;
const generatedRootPath = bridgeRuntimePath ? path.posix.dirname(bridgeRuntimePath) : null;
const extensionModulePaths = generatedRootPath
? files.filter((item) => {
if (!item.startsWith(`${generatedRootPath}/`)) {
return false;
}
const basename = path.posix.basename(item);
return EXTENSION_MODULE_PATTERN.test(basename);
})
: [];
const entryScriptPath =
files.find((item) => ENTRY_SCRIPT_PATTERN.test(path.posix.basename(item))) ?? null;
const wasmPath = files.find((item) => WASM_PATTERN.test(path.posix.basename(item))) ?? null;
return {
bridgeRuntimePath,
extensionModulePaths,
generatedRootPath,
entryScriptPath,
wasmPath,
};
}
function guessContentType(absolutePath: string): string {
const extension = path.extname(absolutePath).toLowerCase();
if (extension === ".js" || extension === ".mjs") {
return "application/javascript; charset=utf-8";
}
if (extension === ".wasm") {
return "application/wasm";
}
if (extension === ".json") {
return "application/json; charset=utf-8";
}
if (extension === ".html") {
return "text/html; charset=utf-8";
}
if (extension === ".css") {
return "text/css; charset=utf-8";
}
return "application/octet-stream";
}
export async function GET(
_request: Request,
context: { params: Promise<{ asset?: string[] }> },
) {
const params = await context.params;
const asset = params.asset ?? [];
if (asset.length === 1 && asset[0] === "manifest.json") {
try {
const manifest = await buildRuntimeManifest();
return NextResponse.json(manifest, {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return NextResponse.json(
{
error: "无法生成 leptos-tiptap runtime 清单",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
const relativePath = sanitizeRelativePath(asset);
if (!relativePath) {
return NextResponse.json({ error: "非法 runtime 资源路径" }, { status: 400 });
}
const absolutePath = path.resolve(DIST_ROOT, relativePath);
if (!absolutePath.startsWith(DIST_ROOT + path.sep)) {
return NextResponse.json({ error: "越界访问 runtime 资源被拒绝" }, { status: 403 });
}
try {
const fileContent = await fs.readFile(absolutePath);
return new NextResponse(fileContent, {
status: 200,
headers: {
"Content-Type": guessContentType(absolutePath),
"Cache-Control": "no-store",
},
});
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
return NextResponse.json({ error: "runtime 资源不存在" }, { status: 404 });
}
return NextResponse.json(
{
error: "读取 runtime 资源失败",
detail: error instanceof Error ? error.message : "unknown",
},
{ status: 500 },
);
}
}
@@ -16,7 +16,7 @@ import * as Y from "yjs";
import type { Block } from "@blocknote/core";
import { cn } from "@/lib/utils";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { DocumentStats } from "@/types/page-options";
import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
@@ -36,23 +36,7 @@ import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
interface BlockNoteEditorProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
initialRevision?: number | null;
initialConflictDetectionKey?: string | null;
pageOptions: PageOptionsState;
readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void;
onPersistedMetaChange?: (payload: {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
}
import type { BlockNoteEditorProps } from "@/components/editor/editor-host-types";
const extractInitialBlocks = (content: unknown): Json | undefined => {
if (Array.isArray(content) && content.length > 0) {
@@ -27,6 +27,8 @@ import {
moveDocumentCommand,
renameDocumentCommand,
} from "@/lib/documents/tree-command-client";
import { EditorHost } from "@/components/editor/editor-host";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -38,8 +40,6 @@ const BlockNoteEditor = dynamic(
},
);
// 说明:这些组件都不是“进入文档页首屏”所必需。
// 先拆成独立 chunk,避免 /documents/[id] 首次编译时把评论、历史、弹层、检查器等整串依赖一并拉进来。
const PageOptionsSidebar = dynamic(
() => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar),
{ ssr: false },
@@ -89,6 +89,7 @@ export interface DocumentContentProps {
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
openTableId?: string | null;
editorHostKind?: EditorHostKind;
readOnly?: boolean;
disableDownload?: boolean;
disableCopy?: boolean;
@@ -124,6 +125,7 @@ export function DocumentContent({
initialOptions,
initialStats,
openTableId,
editorHostKind = "blocknote",
readOnly = false,
disableDownload = false,
disableCopy = false,
@@ -154,8 +156,12 @@ export function DocumentContent({
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const [isEditing, setIsEditing] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
const shouldUseExperimentalHost =
editorHostKind === "leptos_tiptap_inline" ||
editorHostKind === "leptos_tiptap_iframe_debug";
const shouldStartEditing = canEditDocument;
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing);
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editorUnmountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingOpenTableRef = useRef<string | null>(null);
@@ -246,20 +252,17 @@ export function DocumentContent({
editorBridge.openTableFullScreen(tableId);
// 清理 URL 参数,避免刷新/回退时重复触发
if (typeof window !== "undefined") {
try {
const url = new URL(window.location.href);
url.searchParams.delete("openTableId");
window.history.replaceState({}, "", url.toString());
} catch {
// fallback:不影响主流程
router.replace(`/documents/${documentId}`);
}
}
}, [documentId, editorBridge, openTableId, router]);
useEffect(() => {
setPageTitle(title ?? "无标题");
}, [title]);
@@ -347,7 +350,6 @@ export function DocumentContent({
clearTimeout(contentLoadingTimerRef.current);
contentLoadingTimerRef.current = null;
}
// 避免"秒闪"的加载提示:只有当加载超过短阈值时才显示提示
contentLoadingTimerRef.current = setTimeout(() => {
if (!canceled) {
setShowContentLoadingIndicator(true);
@@ -358,9 +360,9 @@ export function DocumentContent({
const response = await fetch(
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{
method: "GET",
credentials: "include",
signal: controller.signal,
method: "GET",
credentials: "include",
signal: controller.signal,
},
);
if (!response.ok) {
@@ -586,7 +588,6 @@ export function DocumentContent({
window.alert(successMessage);
return;
} catch {
// ignore and fallback
}
}
window.prompt("复制失败,请手动复制内容", text);
@@ -890,12 +891,10 @@ export function DocumentContent({
<p className="mt-1 text-sm text-gray-500"></p>
) : options.protectEditing && isEditing ? (
<p className="mt-1 text-sm text-[#b91c1c]"></p>
) : !isEditing && canEditDocument ? (
<p className="mt-1 text-sm text-gray-500"></p>
) : null}
<p className="text-sm text-wolai-text-secondary">{formattedUpdatedAt}</p>
</div>
{canEditDocument && (
{canEditDocument && false && (
<div className="flex shrink-0 items-center gap-2">
{isEditing ? (
<Button type="button" variant="outline" size="sm" onClick={handleExitEditMode}>
@@ -938,22 +937,44 @@ export function DocumentContent({
<div className="relative">
{keepEditorMounted && (
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
setContentRevision(revision);
setConflictDetectionKey(nextConflictDetectionKey);
}}
/>
{shouldUseExperimentalHost ? (
<EditorHost
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={title}
hostKind={editorHostKind}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
/>
) : (
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={title}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
/>
)}
</div>
)}
{!isEditing && (
@@ -0,0 +1,29 @@
export type EditorHostKind =
| "blocknote"
| "leptos_tiptap_inline"
| "leptos_tiptap_iframe_debug";
export interface EditorHostConfig {
kind: EditorHostKind;
}
const DEFAULT_EDITOR_HOST_KIND: EditorHostKind = "blocknote";
export function normalizeEditorHostKind(value: unknown): EditorHostKind {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "leptos_tiptap_inline") {
return "leptos_tiptap_inline";
}
if (normalized === "leptos_tiptap_iframe_debug" || normalized === "leptos_tiptap") {
return "leptos_tiptap_iframe_debug";
}
return DEFAULT_EDITOR_HOST_KIND;
}
export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind {
return normalizeEditorHostKind(value);
}
export function getEditorHostConfig(value?: unknown): EditorHostConfig {
return { kind: getEditorHostKindFromEnv(value) };
}
@@ -0,0 +1,54 @@
"use client";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import type { ReferenceTarget } from "@/types/search";
import type { EditorHostKind } from "@/components/editor/editor-host-config";
export interface DocumentEditorHostProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
title?: string | null;
hostKind?: EditorHostKind;
initialRevision?: number | null;
initialConflictDetectionKey?: string | null;
pageOptions: PageOptionsState;
readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void;
onPersistedMetaChange?: (payload: {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
}
export type BlockNoteEditorProps = DocumentEditorHostProps;
export type LeptosTiptapHostBridgeState = {
ready: boolean;
runtimeName: string;
runtimeVersion: string;
runtimeUrl: string;
documentId: string;
workspaceId: string;
status: string;
lastChangeAt?: string | null;
lastSaveRequestAt?: string | null;
lastError?: string | null;
};
export type LeptosTiptapHostBridgeEventDetail = {
documentId: string;
workspaceId: string;
runtimeUrl: string;
status?: string;
payload?: unknown;
snapshot?: { blocks: Json; stats?: DocumentStats };
references?: ReferenceTarget[];
revision?: number | null;
conflictDetectionKey?: string | null;
error?: string | null;
at?: string;
};
@@ -0,0 +1,35 @@
"use client";
import dynamic from "next/dynamic";
import type { ComponentType } from "react";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
const LeptosTiptapIframeDebugEditor = dynamic(
() => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapInlineEditor = dynamic(
() =>
import("@/components/editor/leptos-tiptap-inline-editor-host").then(
(mod) => mod.LeptosTiptapInlineEditorHost,
),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
export function EditorHost(props: DocumentEditorHostProps) {
if (props.hostKind === "leptos_tiptap_inline") {
return <LeptosTiptapInlineEditor {...props} />;
}
return <LeptosTiptapIframeDebugEditor {...props} />;
}
@@ -0,0 +1,442 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type {
DocumentEditorHostProps,
LeptosTiptapHostBridgeState,
} from "@/components/editor/editor-host-types";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { DocumentStats } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
const RUNTIME_PORT = 8123;
const RUNTIME_NAME = "8123-leptos-tiptap-runtime";
const RUNTIME_VERSION = "1.1.0";
const EVENT_PREFIX = "mnote:leptos-tiptap-spike";
const READY_EVENT = `${EVENT_PREFIX}:ready`;
const CHANGE_EVENT = `${EVENT_PREFIX}:change`;
const STATE_EVENT = `${EVENT_PREFIX}:state`;
const SAVE_REQUEST_EVENT = `${EVENT_PREFIX}:save-request`;
const HEIGHT_EVENT = `${EVENT_PREFIX}:height`;
const BOOTSTRAP_EVENT = `${EVENT_PREFIX}:bootstrap`;
const REPLACE_DOCUMENT_EVENT = `${EVENT_PREFIX}:replace-document`;
const PROTOCOL = "mnote.leptos_tiptap.bridge.v1";
const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph", attrs: {}, content: [] }] } as const;
const FALLBACK_IFRAME_MIN_HEIGHT = 720;
type HostEnvelope<T = unknown> = {
protocol?: string;
runtime?: string;
version?: string;
source?: string;
event?: string;
payload?: T;
};
type HostDocumentPayload = {
title?: string | null;
content?: unknown;
meta?: {
dirty_count?: number;
editor_focused?: boolean;
slash_open?: boolean;
toolbar_open?: boolean;
revision?: number | null;
conflict_detection_key?: string | null;
};
};
type HeightPayload = {
height?: number;
};
type BootstrapPayload = {
documentId: string;
workspaceId: string;
title: string | null;
content: unknown;
revision: number | null;
conflictDetectionKey: string | null;
readOnly: boolean;
};
function resolveRuntimeUrl() {
if (typeof window === "undefined") {
return `http://localhost:${RUNTIME_PORT}/`;
}
const cfg = getMnoteRuntimeConfig();
if (cfg.mnoteWebBaseUrl) {
try {
const url = new URL(cfg.mnoteWebBaseUrl);
url.port = String(RUNTIME_PORT);
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
// ignore
}
}
try {
const url = new URL(window.location.href);
url.port = String(RUNTIME_PORT);
url.pathname = "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return `http://localhost:${RUNTIME_PORT}/`;
}
}
function normalizeRuntimeDoc(tiptapDoc: Json) {
if (!tiptapDoc || typeof tiptapDoc !== "object") {
return EMPTY_DOC;
}
const doc = tiptapDoc as { type?: unknown; content?: unknown };
if (doc.type !== "doc") {
return EMPTY_DOC;
}
if (!Array.isArray(doc.content) || doc.content.length === 0) {
return EMPTY_DOC;
}
return tiptapDoc;
}
function buildIframeSrc(runtimeUrl: string, props: DocumentEditorHostProps, reloadKey: number) {
const url = new URL(runtimeUrl);
url.searchParams.set("embedded", "1");
url.searchParams.set("host", "leptos_tiptap");
url.searchParams.set("documentId", props.documentId);
url.searchParams.set("workspaceId", props.workspaceId);
url.searchParams.set("reloadKey", String(reloadKey));
return url.toString();
}
function isBridgeEnvelope(value: unknown): value is HostEnvelope {
if (!value || typeof value !== "object") {
return false;
}
const maybe = value as HostEnvelope;
return maybe.protocol === PROTOCOL && maybe.runtime === RUNTIME_NAME && maybe.source === EVENT_PREFIX;
}
function flattenText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(flattenText).join("");
if (value && typeof value === "object") {
const obj = value as { text?: unknown; content?: unknown };
return `${flattenText(obj.text)}${flattenText(obj.content)}`;
}
return "";
}
function buildStats(blocks: Json): DocumentStats {
const items = Array.isArray(blocks) ? blocks : [];
const text = items
.map((item) => (item && typeof item === "object" ? flattenText((item as { content?: unknown }).content) : ""))
.join("\n")
.trim();
const todoItems = items.filter((item) => item && typeof item === "object" && (item as { type?: unknown }).type === "todo");
const todoDone = todoItems.filter(
(item) => item && typeof item === "object" && Boolean((item as { props?: { checked?: unknown } }).props?.checked),
).length;
return {
wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
characterCount: text.length,
blockCount: items.length,
todoTotal: todoItems.length,
todoDone,
};
}
function toIsoNow() {
return new Date().toISOString();
}
function coerceHeight(value: unknown): number {
const height = Number(value);
if (!Number.isFinite(height)) {
return FALLBACK_IFRAME_MIN_HEIGHT;
}
return Math.max(FALLBACK_IFRAME_MIN_HEIGHT, Math.ceil(height));
}
function postBridgeMessage(targetWindow: Window | null | undefined, event: string, payload: unknown) {
if (!targetWindow) return;
const envelope: HostEnvelope = {
protocol: PROTOCOL,
runtime: RUNTIME_NAME,
version: RUNTIME_VERSION,
source: EVENT_PREFIX,
event,
payload,
};
targetWindow.postMessage(envelope, "*");
}
export function LeptosTiptapEditorHost(props: DocumentEditorHostProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const runtimeUrl = useMemo(() => resolveRuntimeUrl(), []);
const registerBridge = useEditorBridgeStore((state) => state.registerBridge);
const onSnapshotRef = useRef(props.onSnapshot);
const onStatsChangeRef = useRef(props.onStatsChange);
const onPersistedMetaChangeRef = useRef(props.onPersistedMetaChange);
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(props.initialConflictDetectionKey ?? null);
const bootstrapPayloadRef = useRef<BootstrapPayload | null>(null);
const [runtimeDoc, setRuntimeDoc] = useState<Json>(() => tiptapDocFromBlocks(props.initialContent as Json) as Json);
const [reloadKey, setReloadKey] = useState(0);
const [iframeHeight, setIframeHeight] = useState(FALLBACK_IFRAME_MIN_HEIGHT);
const [bridgeState, setBridgeState] = useState<LeptosTiptapHostBridgeState>({
ready: false,
runtimeName: RUNTIME_NAME,
runtimeVersion: RUNTIME_VERSION,
runtimeUrl,
documentId: props.documentId,
workspaceId: props.workspaceId,
status: "booting",
lastChangeAt: null,
lastSaveRequestAt: null,
lastError: null,
});
const iframeSrc = useMemo(
() => buildIframeSrc(runtimeUrl, props, reloadKey),
[props, reloadKey, runtimeUrl],
);
useEffect(() => {
onSnapshotRef.current = props.onSnapshot;
onStatsChangeRef.current = props.onStatsChange;
onPersistedMetaChangeRef.current = props.onPersistedMetaChange;
revisionRef.current = props.initialRevision ?? null;
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [props.initialConflictDetectionKey, props.initialRevision, props.onPersistedMetaChange, props.onSnapshot, props.onStatsChange]);
useEffect(() => {
const nextRuntimeDoc = tiptapDocFromBlocks(props.initialContent as Json) as Json;
setRuntimeDoc(nextRuntimeDoc);
bootstrapPayloadRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: normalizeRuntimeDoc(nextRuntimeDoc),
revision: props.initialRevision ?? null,
conflictDetectionKey: props.initialConflictDetectionKey ?? null,
readOnly: Boolean(props.readOnly),
};
setIframeHeight(FALLBACK_IFRAME_MIN_HEIGHT);
setReloadKey((value) => value + 1);
setBridgeState((prev) => ({
...prev,
ready: false,
documentId: props.documentId,
workspaceId: props.workspaceId,
status: "booting",
lastError: null,
}));
}, [props.documentId, props.initialContent, props.initialConflictDetectionKey, props.initialRevision, props.readOnly, props.title, props.workspaceId]);
useEffect(() => {
const iframeWindow = iframeRef.current?.contentWindow;
if (!iframeWindow) return;
const payload = bootstrapPayloadRef.current;
if (!payload) return;
postBridgeMessage(iframeWindow, REPLACE_DOCUMENT_EVENT, payload);
setBridgeState((prev) => ({ ...prev, status: prev.ready ? "reloading" : prev.status }));
}, [runtimeDoc]);
useEffect(() => {
const bridge: EditorReferenceBridge = {
insertInlineReference: () => ({ blockId: null }),
insertEmbedReference: () => ({ blockId: null }),
undo: () => {
// 现阶段 iframe runtime 还没有父->子撤销命令,先保持最小 bridge 兼容。
},
redo: () => {
// 现阶段 iframe runtime 还没有父->子重做命令,先保持最小 bridge 兼容。
},
getCursorBlockId: () => null,
replaceWithSnapshot: (blocks: Json) => {
const nextRuntimeDoc = tiptapDocFromBlocks(blocks) as Json;
setRuntimeDoc(nextRuntimeDoc);
bootstrapPayloadRef.current = {
documentId: props.documentId,
workspaceId: props.workspaceId,
title: props.title ?? null,
content: normalizeRuntimeDoc(nextRuntimeDoc),
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
readOnly: Boolean(props.readOnly),
};
postBridgeMessage(iframeRef.current?.contentWindow, REPLACE_DOCUMENT_EVENT, bootstrapPayloadRef.current);
setBridgeState((prev) => ({
...prev,
ready: false,
status: "reloading",
lastError: null,
}));
},
};
registerBridge(bridge);
return () => registerBridge(null);
}, [props.documentId, props.readOnly, props.title, props.workspaceId, registerBridge]);
useEffect(() => {
let disposed = false;
const expectedOrigin = (() => {
try {
return new URL(runtimeUrl).origin;
} catch {
return "";
}
})();
const publishSnapshot = (payload: HostDocumentPayload) => {
const blocks = blocksFromTiptapDoc(payload.content) as Json;
const stats = buildStats(blocks);
setRuntimeDoc(normalizeRuntimeDoc(payload.content as Json) as Json);
onSnapshotRef.current?.({ blocks, stats });
onStatsChangeRef.current?.(stats);
return { blocks, stats };
};
const saveSnapshot = async (payload: HostDocumentPayload) => {
const normalizedRuntimeDoc = normalizeRuntimeDoc(payload.content as Json) as Json;
const { blocks } = publishSnapshot(payload);
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: payload.meta?.revision ?? revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
normalizedRuntimeDoc,
props.documentId,
),
content: blocks,
tiptapDocument: normalizedRuntimeDoc,
conflictDetectionKey:
payload.meta?.conflict_detection_key ?? conflictDetectionKeyRef.current,
snapshotCapturedAt: toIsoNow(),
blockCount: Array.isArray(blocks) ? blocks.length : 0,
}),
),
});
const nextMeta = await response.json().catch(() => null);
if (!response.ok) {
const errorMessage = typeof nextMeta?.error === "string" ? nextMeta.error : `保存失败(${response.status}`;
throw new Error(errorMessage);
}
const nextRevision = typeof nextMeta?.revision === "number" ? nextMeta.revision : payload.meta?.revision ?? null;
const nextConflictDetectionKey =
typeof nextMeta?.conflictDetectionKey === "string"
? nextMeta.conflictDetectionKey
: payload.meta?.conflict_detection_key ?? null;
revisionRef.current = nextRevision;
conflictDetectionKeyRef.current = nextConflictDetectionKey;
onPersistedMetaChangeRef.current?.({
revision: nextRevision,
conflictDetectionKey: nextConflictDetectionKey,
});
};
const handleMessage = (event: MessageEvent<HostEnvelope>) => {
if (!expectedOrigin || event.origin !== expectedOrigin) {
return;
}
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
if (!isBridgeEnvelope(event.data)) {
return;
}
const eventName = typeof event.data.event === "string" ? event.data.event : "";
const payload = (event.data.payload ?? {}) as HostDocumentPayload & HeightPayload;
if (eventName === READY_EVENT) {
const bootstrapPayload = bootstrapPayloadRef.current;
if (bootstrapPayload) {
postBridgeMessage(iframeRef.current?.contentWindow, BOOTSTRAP_EVENT, bootstrapPayload);
}
setBridgeState((prev) => ({ ...prev, ready: true, status: "ready", lastError: null }));
return;
}
if (eventName === STATE_EVENT) {
setBridgeState((prev) => ({ ...prev, ready: true, status: "editing", lastError: null }));
return;
}
if (eventName === HEIGHT_EVENT) {
setIframeHeight(coerceHeight(payload.height));
return;
}
if (eventName === CHANGE_EVENT) {
publishSnapshot(payload);
setBridgeState((prev) => ({ ...prev, ready: true, status: "dirty", lastChangeAt: toIsoNow(), lastError: null }));
return;
}
if (eventName === SAVE_REQUEST_EVENT) {
setBridgeState((prev) => ({
...prev,
ready: true,
status: "saving",
lastSaveRequestAt: toIsoNow(),
lastError: null,
}));
void saveSnapshot(payload)
.then(() => {
if (disposed) return;
setBridgeState((prev) => ({ ...prev, ready: true, status: "saved", lastError: null }));
})
.catch((error) => {
if (disposed) return;
setBridgeState((prev) => ({
...prev,
ready: true,
status: "error",
lastError: error instanceof Error ? error.message : "保存失败",
}));
});
}
};
window.addEventListener("message", handleMessage);
return () => {
disposed = true;
window.removeEventListener("message", handleMessage);
};
}, [props.documentId, props.workspaceId, runtimeUrl]);
return (
<div
className="rounded-xl border border-amber-200/80 bg-amber-50/70"
data-editor-host-kind="leptos_tiptap_iframe_debug"
>
<div className="flex items-center justify-between border-b border-amber-200 px-3 py-2 text-xs text-amber-800">
<span>`iframe + postMessage` prototype</span>
<span>{bridgeState.status}</span>
</div>
<iframe
key={`${props.documentId}:${reloadKey}`}
ref={iframeRef}
src={iframeSrc}
title="Leptos Tiptap Editor Debug Bridge"
className="w-full border-0"
style={{ height: `${iframeHeight}px`, minHeight: `${FALLBACK_IFRAME_MIN_HEIGHT}px` }}
data-bridge-status={bridgeState.status}
data-runtime-name={bridgeState.runtimeName}
data-runtime-version={bridgeState.runtimeVersion}
/>
</div>
);
}
@@ -0,0 +1,574 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
import type { DocumentStats } from "@/types/page-options";
import type { Json } from "@/types/supabase";
import {
blocksFromTiptapDoc,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
} from "@/lib/documents/tiptap-content-converter";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import { loadLeptosTiptapRuntime } from "@/components/editor/leptos-tiptap-runtime-loader";
type RuntimeSelectionState = Record<string, boolean | number | null | undefined> & {
current_block_index?: number | null;
current_block_from?: number | null;
current_block_to?: number | null;
selection_from?: number | null;
selection_to?: number | null;
selection_empty?: boolean;
};
type RuntimeSnapshot = {
tiptapDocument: unknown;
blocks: Json;
stats: DocumentStats;
};
const INLINE_RUNTIME_STATUS = {
booting: "booting",
ready: "ready",
dirty: "dirty",
saving: "saving",
saved: "saved",
error: "error",
} as const;
function flattenText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(flattenText).join("");
if (value && typeof value === "object") {
const record = value as { text?: unknown; content?: unknown };
return `${flattenText(record.text)}${flattenText(record.content)}`;
}
return "";
}
function buildStats(blocks: Json): DocumentStats {
const items = Array.isArray(blocks) ? blocks : [];
const text = items
.map((item) =>
item && typeof item === "object"
? flattenText((item as { content?: unknown }).content)
: "",
)
.join("\n")
.trim();
const todoItems = items.filter(
(item) =>
item && typeof item === "object" && (item as { type?: unknown }).type === "todo",
);
const todoDone = todoItems.filter(
(item) =>
item &&
typeof item === "object" &&
Boolean((item as { props?: { checked?: unknown } }).props?.checked),
).length;
return {
wordCount: text ? text.split(/\s+/).filter(Boolean).length : 0,
characterCount: text.length,
blockCount: items.length,
todoTotal: todoItems.length,
todoDone,
};
}
function normalizeMountId(documentId: string): string {
return `leptos-tiptap-inline-${documentId.replace(/[^a-zA-Z0-9_-]/g, "_")}`;
}
function readSelectionInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
}
function readBlockId(value: unknown): string | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as { id?: unknown; blockId?: unknown };
const candidate =
typeof record.id === "string" && record.id.trim()
? record.id
: typeof record.blockId === "string" && record.blockId.trim()
? record.blockId
: null;
return candidate ? candidate.trim() : null;
}
export function LeptosTiptapInlineEditorHost(props: DocumentEditorHostProps) {
const registerBridge = useEditorBridgeStore((state) => state.registerBridge);
const mountId = useMemo(() => normalizeMountId(props.documentId), [props.documentId]);
const generationRef = useRef<number | null>(null);
const onSnapshotRef = useRef(props.onSnapshot);
const onStatsChangeRef = useRef(props.onStatsChange);
const onPersistedMetaChangeRef = useRef(props.onPersistedMetaChange);
const selectionStateRef = useRef<RuntimeSelectionState>({});
const runtimeLoadedRef = useRef(false);
const revisionRef = useRef<number | null>(props.initialRevision ?? null);
const conflictDetectionKeyRef = useRef<string | null>(
props.initialConflictDetectionKey ?? null,
);
const latestSnapshotRef = useRef<RuntimeSnapshot | null>(null);
const [status, setStatus] = useState<string>(INLINE_RUNTIME_STATUS.booting);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
onSnapshotRef.current = props.onSnapshot;
onStatsChangeRef.current = props.onStatsChange;
onPersistedMetaChangeRef.current = props.onPersistedMetaChange;
revisionRef.current = props.initialRevision ?? null;
conflictDetectionKeyRef.current = props.initialConflictDetectionKey ?? null;
}, [
props.initialConflictDetectionKey,
props.initialRevision,
props.onPersistedMetaChange,
props.onSnapshot,
props.onStatsChange,
]);
const readCurrentSnapshot = async (): Promise<RuntimeSnapshot> => {
const generation = generationRef.current;
if (!runtimeLoadedRef.current || generation == null) {
throw new Error("inline host 尚未准备就绪");
}
const runtime = await loadLeptosTiptapRuntime();
const response = runtime.bridge.document({
id: mountId,
generation,
request: {
kind: "get_content",
format: "json",
},
});
if (!response.ok) {
throw new Error(response.error.message);
}
const tiptapDocument = response.value.content.value;
const blocks = blocksFromTiptapDoc(tiptapDocument) as Json;
const stats = buildStats(blocks);
const snapshot = { tiptapDocument, blocks, stats };
latestSnapshotRef.current = snapshot;
return snapshot;
};
const publishSnapshot = async () => {
const snapshot = await readCurrentSnapshot();
onSnapshotRef.current?.({
blocks: snapshot.blocks,
stats: snapshot.stats,
});
onStatsChangeRef.current?.(snapshot.stats);
return snapshot;
};
const persistSnapshot = async () => {
setStatus(INLINE_RUNTIME_STATUS.saving);
const snapshot = latestSnapshotRef.current ?? (await readCurrentSnapshot());
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId: props.documentId,
workspaceId: props.workspaceId,
revision: revisionRef.current,
editorDocument: editorBlockDocumentFromTiptapDoc(
snapshot.tiptapDocument,
props.documentId,
),
content: snapshot.blocks,
tiptapDocument: snapshot.tiptapDocument,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount: snapshot.stats.blockCount,
}),
),
});
const payload = (await response.json().catch(() => null)) as
| {
error?: string;
revision?: number | null;
conflictDetectionKey?: string | null;
}
| null;
if (!response.ok) {
throw new Error(payload?.error ?? `保存失败(${response.status}`);
}
revisionRef.current =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
conflictDetectionKeyRef.current =
typeof payload?.conflictDetectionKey === "string" &&
payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
setStatus(INLINE_RUNTIME_STATUS.saved);
setErrorMessage(null);
};
const syncExternalBlocksToRuntime = useCallback(
async (blocks: Json, options?: { emitUpdate?: boolean }) => {
const generation = generationRef.current;
if (!runtimeLoadedRef.current || generation == null) {
return;
}
const nextDocument = tiptapDocFromBlocks(blocks);
const nextStats = buildStats(blocks);
latestSnapshotRef.current = {
tiptapDocument: nextDocument,
blocks,
stats: nextStats,
};
onStatsChangeRef.current?.(nextStats);
const runtime = await loadLeptosTiptapRuntime();
const response = runtime.bridge.document({
id: mountId,
generation,
request: {
kind: "set_content",
content: {
format: "json",
value: nextDocument,
},
options: {
emit_update: Boolean(options?.emitUpdate),
},
},
});
if (!response.ok) {
throw new Error(response.error.message);
}
},
[mountId],
);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(error instanceof Error ? error.message : "保存失败");
});
}, 800);
const executeRuntimeCommand = useCallback(async (command: unknown) => {
const generation = generationRef.current;
if (!runtimeLoadedRef.current || generation == null) {
throw new Error("inline host 尚未准备就绪");
}
const runtime = await loadLeptosTiptapRuntime();
const response = runtime.bridge.command({
id: mountId,
generation,
command,
});
if (!response.ok) {
throw new Error(response.error.message);
}
}, [mountId]);
const resolveCurrentBlockIndex = () =>
readSelectionInteger(selectionStateRef.current.current_block_index);
const resolveCurrentBlockRange = () => {
const from = readSelectionInteger(selectionStateRef.current.current_block_from);
const to = readSelectionInteger(selectionStateRef.current.current_block_to);
if (from == null || to == null || to < from) {
return null;
}
return { from, to };
};
const resolveCurrentBlockId = () => {
const index = resolveCurrentBlockIndex();
if (index == null) {
return null;
}
const blocks = latestSnapshotRef.current?.blocks;
if (!Array.isArray(blocks)) {
return null;
}
return readBlockId(blocks[index]);
};
useEffect(() => {
let disposed = false;
runtimeLoadedRef.current = false;
generationRef.current = null;
selectionStateRef.current = {};
latestSnapshotRef.current = null;
setStatus(INLINE_RUNTIME_STATUS.booting);
setErrorMessage(null);
const initialDocument = tiptapDocFromBlocks(props.initialContent as Json);
void loadLeptosTiptapRuntime()
.then((runtime) => {
if (disposed) return;
runtime.bridge.create(
{
id: mountId,
content: {
format: "json",
value: initialDocument,
},
editable: !props.readOnly,
placeholder: "输入 '/' 选择,AI 将直接进入正文命令链",
extensions: [
"blockquote",
"bold",
"bullet_list",
"code",
"code_block",
"document",
"dropcursor",
"gapcursor",
"hard_break",
"heading",
"history",
"horizontal_rule",
"italic",
"link",
"list_item",
"ordered_list",
"paragraph",
"placeholder",
"strike",
"task_item",
"task_list",
"text",
"text_align",
"text_style",
"underline",
],
},
({ generation }) => {
if (disposed) return;
generationRef.current = generation;
runtimeLoadedRef.current = true;
setStatus(INLINE_RUNTIME_STATUS.ready);
void publishSnapshot().catch(() => {
// 忽略初次快照读取错误,保留编辑器可用。
});
},
() => {
if (disposed) return;
setStatus(INLINE_RUNTIME_STATUS.dirty);
void publishSnapshot().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "读取 inline editor 快照失败",
);
});
debouncedPersist();
},
(selection) => {
selectionStateRef.current = selection as RuntimeSelectionState;
},
(error) => {
if (disposed) return;
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(error.message);
},
);
})
.catch((error) => {
if (disposed) return;
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "加载 leptos-tiptap runtime 失败",
);
});
return () => {
disposed = true;
debouncedPersist.cancel();
if (runtimeLoadedRef.current) {
void loadLeptosTiptapRuntime()
.then((runtime) => runtime.bridge.destroy(mountId))
.catch(() => {
// ignore
});
}
runtimeLoadedRef.current = false;
generationRef.current = null;
registerBridge(null);
};
}, [mountId, props.documentId, props.readOnly, registerBridge, debouncedPersist]);
useEffect(() => {
const currentBlocks = latestSnapshotRef.current?.blocks;
if (currentBlocks === props.initialContent) {
return;
}
if (!runtimeLoadedRef.current || generationRef.current == null) {
return;
}
void syncExternalBlocksToRuntime(props.initialContent as Json).catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "同步 inline editor 外部内容失败",
);
});
}, [props.initialContent, syncExternalBlocksToRuntime]);
useEffect(() => {
if (!runtimeLoadedRef.current || generationRef.current == null) {
return;
}
void executeRuntimeCommand({
kind: "set_editable",
editable: !props.readOnly,
})
.catch(() => {
// ignore
});
}, [executeRuntimeCommand, mountId, props.readOnly]);
useEffect(() => {
const bridge: EditorReferenceBridge = {
insertInlineReference: (target, aliasText) => {
const blockId = resolveCurrentBlockId();
const label = aliasText?.trim() || target.title || "无标题";
const href = `/documents/${target.id}`;
void (async () => {
await executeRuntimeCommand({ kind: "focus" });
await executeRuntimeCommand({
kind: "insert_content",
content: {
format: "json",
value: [
{
type: "text",
text: label,
marks: [{ type: "link", attrs: { href } }],
},
{
type: "text",
text: " ",
},
],
},
});
})().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "插入行内引用失败",
);
});
return { blockId };
},
insertEmbedReference: (target) => {
const blockId = resolveCurrentBlockId();
const blockRange = resolveCurrentBlockRange();
const href = `/documents/${target.id}`;
const insertCommand = blockRange
? {
kind: "insert_content_at" as const,
position: blockRange.to + 1,
content: {
format: "json" as const,
value: {
type: "paragraph",
content: [
{
type: "text",
text: target.title || "无标题",
marks: [{ type: "link", attrs: { href } }],
},
],
},
},
options: {
update_selection: true,
},
}
: {
kind: "insert_content" as const,
content: {
format: "json" as const,
value: {
type: "paragraph",
content: [
{
type: "text",
text: target.title || "无标题",
marks: [{ type: "link", attrs: { href } }],
},
],
},
},
options: {
update_selection: true,
},
};
void (async () => {
await executeRuntimeCommand({ kind: "focus" });
await executeRuntimeCommand(insertCommand);
await executeRuntimeCommand({ kind: "select_textblock_end" });
})().catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "插入块级引用失败",
);
});
return { blockId };
},
undo: () => {
void executeRuntimeCommand({ kind: "undo" }).catch(() => {
// ignore
});
},
redo: () => {
void executeRuntimeCommand({ kind: "redo" }).catch(() => {
// ignore
});
},
getCursorBlockId: () => resolveCurrentBlockId(),
replaceWithSnapshot: (blocks: Json) => {
if (!runtimeLoadedRef.current || generationRef.current == null) return;
void syncExternalBlocksToRuntime(blocks, { emitUpdate: true })
.catch((error) => {
setStatus(INLINE_RUNTIME_STATUS.error);
setErrorMessage(
error instanceof Error ? error.message : "写入 inline editor 内容失败",
);
});
},
};
registerBridge(bridge);
return () => registerBridge(null);
}, [executeRuntimeCommand, mountId, registerBridge, syncExternalBlocksToRuntime]);
return (
<div
className="rounded-xl border border-emerald-200/80 bg-white/90 shadow-sm"
data-editor-host-kind="leptos_tiptap_inline"
data-inline-editor-status={status}
>
<div className="flex items-center justify-between border-b border-emerald-100 px-3 py-2 text-xs text-emerald-700">
<span> `leptos-tiptap inline island`</span>
<span>{status}</span>
</div>
{errorMessage ? (
<div className="border-b border-red-100 bg-red-50 px-3 py-2 text-xs text-red-700">
{errorMessage}
</div>
) : null}
<div
id={mountId}
className="min-h-[720px] px-6 py-5"
data-testid="mnote-leptos-tiptap-inline-editor-root"
/>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
const MANIFEST_URL = "/api/leptos-tiptap-runtime/manifest.json";
type RuntimeManifest = {
bridgeRuntimePath: string | null;
extensionModulePaths: string[];
generatedRootPath: string | null;
entryScriptPath: string | null;
wasmPath: string | null;
};
type BridgeSuccess<T> = {
ok: true;
value: T;
};
type BridgeFailure = {
ok: false;
error: {
kind: string;
message: string;
operation?: string;
};
};
type BridgeResult<T> = BridgeSuccess<T> | BridgeFailure;
type BridgeRuntimeModule = {
init_bridge_runtime: () => void;
create: (
request: unknown,
onReady: (payload: { generation: number }) => void,
onChange: () => void,
onSelectionChange: (selection: Record<string, unknown>) => void,
onError: (error: { kind: string; message: string; operation?: string }) => void,
) => void;
destroy: (id: string) => void;
command: (request: unknown) => BridgeResult<{ kind: "empty" }>;
document: (request: unknown) => BridgeResult<{ kind: "content"; content: { format: "json" | "html"; value: unknown } }>;
};
export type LoadedLeptosTiptapRuntime = {
manifest: RuntimeManifest;
bridge: BridgeRuntimeModule;
};
let runtimePromise: Promise<LoadedLeptosTiptapRuntime> | null = null;
function toRuntimeAssetUrl(relativePath: string): string {
return `/api/leptos-tiptap-runtime/${relativePath}`;
}
async function importRuntimeModule<T>(relativePath: string): Promise<T> {
return import(/* webpackIgnore: true */ toRuntimeAssetUrl(relativePath)) as Promise<T>;
}
function extractRegisterFunction(module: Record<string, unknown>): (() => void) | null {
for (const value of Object.values(module)) {
if (typeof value === "function") {
return value as () => void;
}
}
return null;
}
async function fetchRuntimeManifest(): Promise<RuntimeManifest> {
const response = await fetch(MANIFEST_URL, { cache: "no-store" });
if (!response.ok) {
throw new Error(`读取 leptos-tiptap runtime manifest 失败(${response.status}`);
}
const manifest = (await response.json()) as RuntimeManifest;
if (!manifest.bridgeRuntimePath) {
throw new Error("runtime manifest 缺少 bridgeRuntimePath");
}
return manifest;
}
async function loadRuntimeModules(): Promise<LoadedLeptosTiptapRuntime> {
const manifest = await fetchRuntimeManifest();
const bridge = await importRuntimeModule<BridgeRuntimeModule>(manifest.bridgeRuntimePath!);
bridge.init_bridge_runtime();
for (const modulePath of manifest.extensionModulePaths) {
const extensionModule = await importRuntimeModule<Record<string, unknown>>(modulePath);
const register = extractRegisterFunction(extensionModule);
register?.();
}
return { manifest, bridge };
}
export async function loadLeptosTiptapRuntime(): Promise<LoadedLeptosTiptapRuntime> {
if (!runtimePromise) {
runtimePromise = loadRuntimeModules().catch((error) => {
runtimePromise = null;
throw error;
});
}
return runtimePromise;
}
@@ -34,6 +34,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
@@ -1448,7 +1449,12 @@ function SidebarContent({ initialData, sidebarQuery, treeStream }: SidebarConten
});
await refreshTree();
router.push(`/documents/${nextNode.id}`);
const query = new URLSearchParams();
query.set("edit", "1");
if (nextNode.workspace_id) {
query.set("workspaceId", nextNode.workspace_id);
}
router.push(`/documents/${nextNode.id}?${query.toString()}`);
} catch (error) {
window.alert(error instanceof Error ? error.message : "新建页面失败,请稍后再试");
} finally {
@@ -2,12 +2,24 @@ import { describe, expect, it } from "vitest";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
describe("buildDocumentSavePayload", () => {
it("统一规范 documents.save 的共享 payload", () => {
it("优先规范 editorDocument 并从它派生 legacy content", () => {
const payload = buildDocumentSavePayload({
documentId: " doc_1 ",
workspaceId: " ws_1 ",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
editorDocument: {
documentId: "other_doc",
rootBlockIds: ["block_1"],
blocks: [
{
blockId: "block_1",
blockType: "paragraph",
contentNodes: [{ type: "text", text: "新正文" }],
childBlockIds: [],
},
],
},
content: [{ id: "legacy_1", type: "paragraph", content: "旧正文" }],
conflictDetectionKey: " conflict_1 ",
});
@@ -15,27 +27,103 @@ describe("buildDocumentSavePayload", () => {
documentId: "doc_1",
workspaceId: "ws_1",
revision: 3,
content: [{ id: "block_1", type: "paragraph", content: [] }],
editorDocument: {
documentId: "doc_1",
rootBlockIds: ["block_1"],
blocks: [
{
blockId: "block_1",
blockType: "paragraph",
props: {},
contentNodes: [{ type: "text", text: "新正文", marks: [] }],
childBlockIds: [],
},
],
},
content: [{ id: "block_1", type: "paragraph", props: undefined, content: "新正文" }],
tiptapDocument: null,
conflictDetectionKey: "conflict_1",
snapshotCapturedAt: null,
blockCount: null,
});
});
it("非法 revision/conflictDetectionKey 会回退到 null", () => {
it("兼容仅 content 的旧 payload,并补齐 editorDocument", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "",
revision: -1,
content: [],
conflictDetectionKey: " ",
content: [{ id: "heading_1", type: "heading", props: { level: 2 }, content: "章节标题" }],
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: null,
revision: null,
content: [],
editorDocument: {
documentId: "doc_1",
rootBlockIds: ["heading_1"],
blocks: [
{
blockId: "heading_1",
blockType: "heading",
props: { headingLevel: 2 },
contentNodes: [{ type: "text", text: "章节标题", marks: [] }],
childBlockIds: [],
},
],
},
content: [{ id: "heading_1", type: "heading", props: { level: 2 }, content: "章节标题" }],
tiptapDocument: null,
conflictDetectionKey: null,
snapshotCapturedAt: null,
blockCount: null,
});
});
it("支持将 tiptapDocument 适配为 editorDocument 与兼容 content", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
tiptapDocument: {
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: "p_1" },
content: [{ type: "text", text: "来自 tiptap" }],
},
],
},
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: null,
editorDocument: {
documentId: "doc_1",
rootBlockIds: ["p_1"],
blocks: [
{
blockId: "p_1",
blockType: "paragraph",
props: {},
contentNodes: [{ type: "text", text: "来自 tiptap", marks: [] }],
childBlockIds: [],
},
],
},
content: [{ id: "p_1", type: "paragraph", props: undefined, content: "来自 tiptap" }],
tiptapDocument: {
type: "doc",
content: [
{
type: "paragraph",
attrs: { blockId: "p_1" },
content: [{ type: "text", text: "来自 tiptap" }],
},
],
},
conflictDetectionKey: null,
snapshotCapturedAt: null,
blockCount: null,
@@ -53,14 +141,9 @@ describe("buildDocumentSavePayload", () => {
blockCount: 1,
});
expect(payload).toEqual({
documentId: "doc_1",
workspaceId: "ws_1",
revision: 4,
content: [{ id: "block_1" }],
conflictDetectionKey: "doc_1:4",
snapshotCapturedAt: "2026-04-14T14:30:00.000Z",
blockCount: 1,
});
expect(payload.revision).toBe(4);
expect(payload.conflictDetectionKey).toBe("doc_1:4");
expect(payload.snapshotCapturedAt).toBe("2026-04-14T14:30:00.000Z");
expect(payload.blockCount).toBe(1);
});
});
@@ -1,24 +1,78 @@
import type { Json } from "@/types/supabase";
import {
editorBlockDocumentFromContent,
editorBlockDocumentFromTiptapDoc,
legacyBlocksFromEditorBlockDocument,
normalizeEditorBlockDocument,
type EditorBlockDocument,
type TiptapDoc,
} from "@/lib/documents/tiptap-content-converter";
export type DocumentSavePayload = {
documentId: string;
workspaceId: string | null;
revision: number | null;
editorDocument: EditorBlockDocument;
content: Json;
tiptapDocument: TiptapDoc | null;
conflictDetectionKey: string | null;
snapshotCapturedAt: string | null;
blockCount: number | null;
};
export function buildDocumentSavePayload(input: {
type BuildDocumentSavePayloadInput = {
documentId: string;
workspaceId?: string | null;
revision?: number | null;
content: Json;
editorDocument?: unknown;
content?: Json;
tiptapDocument?: unknown;
conflictDetectionKey?: string | null;
snapshotCapturedAt?: string | null;
blockCount?: number | null;
}): DocumentSavePayload {
};
function resolveEditorDocument(
input: BuildDocumentSavePayloadInput,
normalizedDocumentId: string,
): EditorBlockDocument {
if (input.editorDocument != null) {
return normalizeEditorBlockDocument(input.editorDocument, normalizedDocumentId);
}
if (typeof input.content !== "undefined") {
return normalizeEditorBlockDocument(
editorBlockDocumentFromContent(input.content),
normalizedDocumentId,
);
}
if (input.tiptapDocument != null) {
return normalizeEditorBlockDocument(
editorBlockDocumentFromTiptapDoc(input.tiptapDocument, normalizedDocumentId),
normalizedDocumentId,
);
}
return normalizeEditorBlockDocument(
{
documentId: normalizedDocumentId,
rootBlockIds: [],
blocks: [],
},
normalizedDocumentId,
);
}
export function buildDocumentSavePayload(
input: BuildDocumentSavePayloadInput,
): DocumentSavePayload {
const documentId = input.documentId.trim();
const editorDocument = resolveEditorDocument(input, documentId);
const shouldDeriveLegacyContent =
input.editorDocument != null ||
input.tiptapDocument != null ||
typeof input.content === "undefined";
const content: Json = shouldDeriveLegacyContent
? (legacyBlocksFromEditorBlockDocument(editorDocument) as Json)
: (input.content as Json);
const revision =
typeof input.revision === "number" && Number.isInteger(input.revision) && input.revision >= 0
? input.revision
@@ -35,12 +89,18 @@ export function buildDocumentSavePayload(input: {
typeof input.blockCount === "number" && Number.isInteger(input.blockCount) && input.blockCount >= 0
? input.blockCount
: null;
const tiptapDocument =
input.tiptapDocument && typeof input.tiptapDocument === "object"
? (input.tiptapDocument as TiptapDoc)
: null;
return {
documentId: input.documentId.trim(),
documentId,
workspaceId: input.workspaceId?.trim() || null,
revision,
content: input.content,
editorDocument,
content,
tiptapDocument,
conflictDetectionKey,
snapshotCapturedAt,
blockCount,
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { createDocumentCommand } from "@/lib/documents/tree-command-client";
export function SidebarCreateDocumentEntry() {
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
const edit = searchParams.get("edit");
if (edit !== "1") return;
}, [searchParams]);
return null;
}
export async function createDocumentAndOpenEdit(router: ReturnType<typeof useRouter>, parentId: string | null) {
const payload = await createDocumentCommand(parentId);
const query = new URLSearchParams();
query.set("edit", "1");
const workspaceId = payload.workspace_id ?? null;
if (workspaceId) query.set("workspaceId", workspaceId);
router.push(`/documents/${payload.id}?${query.toString()}`);
}
@@ -0,0 +1,54 @@
import {
blocksFromTiptapDoc,
editorBlockDocumentFromContent,
editorBlockDocumentFromTiptapDoc,
tiptapDocFromBlocks,
tiptapDocFromEditorBlockDocument,
} from "@/lib/documents/tiptap-content-converter";
import { describe, expect, it } from "vitest";
describe("tiptap-content-converter", () => {
it("keeps empty content editable", () => {
expect(tiptapDocFromBlocks([])).toEqual({
type: "doc",
content: [{ type: "paragraph", attrs: {}, content: [] }],
});
expect(blocksFromTiptapDoc({ type: "doc", content: [] })).toEqual([]);
});
it("preserves stable block ids through paragraph round-trip", () => {
const blocks = [{ id: "p1", type: "paragraph", content: "你好,世界" }];
expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([
{ id: "p1", type: "paragraph", props: undefined, content: "你好,世界" },
]);
});
it("round-trips p0.5 block families through editor block document", () => {
const document = editorBlockDocumentFromContent([
{ id: "p1", type: "paragraph", content: "段落" },
{ id: "h1", type: "heading", props: { level: 2 }, content: "标题" },
{ id: "b1", type: "bullet_list_item", content: "项目" },
{ id: "n1", type: "numbered_list_item", content: "序号" },
{ id: "t1", type: "todo", props: { checked: true }, content: "待办" },
{ id: "q1", type: "quote", content: "引用" },
{ id: "c1", type: "code_block", props: { language: "ts" }, content: "const x = 1" },
] as never);
const tiptapDoc = tiptapDocFromEditorBlockDocument(document);
const roundTrip = editorBlockDocumentFromTiptapDoc(tiptapDoc, "doc-1");
expect(roundTrip.rootBlockIds).toEqual(["p1", "h1", "b1", "n1", "t1", "q1", "c1"]);
expect(roundTrip.blocks.map((block) => [block.blockId, block.blockType])).toEqual([
["p1", "paragraph"],
["h1", "heading"],
["b1", "bullet_list_item"],
["n1", "numbered_list_item"],
["t1", "todo"],
["q1", "quote"],
["c1", "code_block"],
]);
expect(roundTrip.blocks[1].props?.headingLevel).toBe(2);
expect(roundTrip.blocks[4].props?.checked).toBe(true);
expect(roundTrip.blocks[6].props?.language).toBe("ts");
});
});
@@ -0,0 +1,506 @@
/**
* TipTap EditorBlockDocument
* EditorBlockDocument legacy blocks
*/
export type TiptapMark = {
type: string;
attrs?: Record<string, unknown>;
};
export type TiptapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: TiptapNode[];
text?: string;
marks?: TiptapMark[];
};
export type TiptapDoc = {
type: "doc";
content: TiptapNode[];
};
export type EditorTextMark = "bold" | "italic" | "underline" | "strike" | "code";
export type EditorBlockType =
| "paragraph"
| "heading"
| "bullet_list_item"
| "numbered_list_item"
| "todo"
| "quote"
| "code_block";
export type EditorContentNode = {
type: "text";
text: string;
marks?: EditorTextMark[];
};
export type EditorBlock = {
blockId: string;
blockType: EditorBlockType;
props?: {
headingLevel?: number | null;
checked?: boolean | null;
language?: string | null;
};
contentNodes?: EditorContentNode[];
childBlockIds?: string[];
};
export type EditorBlockDocument = {
documentId: string;
rootBlockIds: string[];
blocks: EditorBlock[];
};
type Json = unknown;
type LegacyBlockLike = {
id?: string;
blockId?: string;
type?: string;
blockType?: string;
content?: unknown;
contentNodes?: unknown;
props?: Record<string, unknown>;
children?: unknown;
};
const EMPTY_DOC: TiptapDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: {}, content: [] }],
};
const MARK_NAME_MAP: Record<string, EditorTextMark> = {
bold: "bold",
italic: "italic",
underline: "underline",
strike: "strike",
code: "code",
};
const BLOCK_ID_ATTR = "blockId";
function coerceTextMark(value: unknown): EditorTextMark | null {
const normalized = String(value ?? "").trim().toLowerCase();
return MARK_NAME_MAP[normalized] ?? null;
}
function readMarks(marks: unknown): EditorTextMark[] {
if (!Array.isArray(marks)) return [];
const normalized = marks
.map((mark) => {
if (typeof mark === "string") return coerceTextMark(mark);
if (mark && typeof mark === "object") return coerceTextMark((mark as { type?: unknown }).type);
return null;
})
.filter((mark): mark is EditorTextMark => Boolean(mark));
return Array.from(new Set(normalized));
}
function toText(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) return value.map(toText).join("");
if (value && typeof value === "object") {
const obj = value as { text?: unknown; content?: unknown; value?: unknown; contentNodes?: unknown };
return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}${toText(obj.contentNodes)}`;
}
return "";
}
function clampHeadingLevel(value: unknown): number {
const level = Number(value);
return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;
}
function normalizeBlockId(value: unknown, fallback: string): string {
const raw = typeof value === "string" ? value.trim() : "";
return raw || fallback;
}
function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {
if (Array.isArray(input)) {
const nodes = input.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };
if (record.type === "text") {
const text = toText(record.text);
return text ? [{ type: "text" as const, text, marks: readMarks(record.marks) }] : [];
}
if (record.payload && typeof record.payload === "object") {
const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };
if (payload.type === "text") {
const text = toText(payload.text);
return text ? [{ type: "text" as const, text, marks: readMarks(payload.marks) }] : [];
}
}
const text = toText(item);
return text ? [{ type: "text" as const, text, marks: [] }] : [];
});
return nodes;
}
const text = toText(input);
return text ? [{ type: "text", text, marks: [] }] : [];
}
function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBlock {
const blockType = String(block.blockType ?? block.type ?? "paragraph").trim().toLowerCase();
const blockId = normalizeBlockId(block.blockId ?? block.id, `block-${index + 1}`);
const props = block.props ?? {};
const contentNodes = normalizeEditorContentNodes(block.contentNodes ?? block.content ?? props.content ?? props.text ?? props.title);
switch (blockType) {
case "heading":
return {
blockId,
blockType: "heading",
props: { headingLevel: clampHeadingLevel(props.level ?? props.headingLevel) },
contentNodes,
childBlockIds: [],
};
case "bullet_list_item":
case "bullet_list":
case "bullet-list":
return { blockId, blockType: "bullet_list_item", props: {}, contentNodes, childBlockIds: [] };
case "numbered_list_item":
case "ordered_list":
case "ordered-list":
return { blockId, blockType: "numbered_list_item", props: {}, contentNodes, childBlockIds: [] };
case "todo":
case "task":
return {
blockId,
blockType: "todo",
props: { checked: Boolean(props.checked) },
contentNodes,
childBlockIds: [],
};
case "quote":
case "blockquote":
return { blockId, blockType: "quote", props: {}, contentNodes, childBlockIds: [] };
case "code":
case "code_block":
case "code-block":
return {
blockId,
blockType: "code_block",
props: { language: typeof props.language === "string" ? props.language : null },
contentNodes,
childBlockIds: [],
};
case "paragraph":
case "text":
default:
return { blockId, blockType: "paragraph", props: {}, contentNodes, childBlockIds: [] };
}
}
function normalizeInputToEditorDocument(value: Json): EditorBlockDocument {
if (value && typeof value === "object" && !Array.isArray(value)) {
const maybe = value as Partial<EditorBlockDocument>;
if (typeof maybe.documentId === "string" && Array.isArray(maybe.blocks)) {
const normalizedBlocks = maybe.blocks.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index));
const rootBlockIds = Array.isArray(maybe.rootBlockIds) && maybe.rootBlockIds.length > 0
? maybe.rootBlockIds.map((id, index) => normalizeBlockId(id, normalizedBlocks[index]?.blockId ?? `block-${index + 1}`))
: normalizedBlocks.map((block) => block.blockId);
return {
documentId: maybe.documentId,
rootBlockIds,
blocks: normalizedBlocks,
};
}
if (Array.isArray((value as { blocks?: unknown }).blocks)) {
const blocks = ((value as { blocks: unknown[] }).blocks ?? []).map((block, index) =>
normalizeLegacyBlock(block as LegacyBlockLike, index),
);
return {
documentId: typeof (value as { documentId?: unknown }).documentId === "string"
? String((value as { documentId?: unknown }).documentId)
: "document",
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
}
}
const blocks = Array.isArray(value)
? value.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index))
: [];
return {
documentId: "document",
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
}
function textNodesToInline(contentNodes: EditorContentNode[] | undefined): TiptapNode[] {
return (contentNodes ?? []).flatMap((node) => {
const text = toText(node.text);
if (!text) return [];
const marks = (node.marks ?? [])
.map((mark) => ({ type: mark }))
.filter((mark) => Boolean(mark.type));
return [{ type: "text", text, marks: marks.length > 0 ? marks : undefined }];
});
}
function blockToTiptapNode(block: EditorBlock): TiptapNode {
const commonAttrs: Record<string, unknown> = { [BLOCK_ID_ATTR]: block.blockId };
switch (block.blockType) {
case "heading":
return {
type: "heading",
attrs: { ...commonAttrs, level: clampHeadingLevel(block.props?.headingLevel) },
content: textNodesToInline(block.contentNodes),
};
case "bullet_list_item":
return {
type: "bulletList",
attrs: commonAttrs,
content: [{ type: "listItem", attrs: commonAttrs, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
};
case "numbered_list_item":
return {
type: "orderedList",
attrs: commonAttrs,
content: [{ type: "listItem", attrs: commonAttrs, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
};
case "todo":
return {
type: "taskList",
attrs: commonAttrs,
content: [{ type: "taskItem", attrs: { ...commonAttrs, checked: Boolean(block.props?.checked) }, content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],
};
case "quote":
return {
type: "blockquote",
attrs: commonAttrs,
content: [{ type: "paragraph", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }],
};
case "code_block":
return {
type: "codeBlock",
attrs: { ...commonAttrs, language: block.props?.language ?? null },
content: textNodesToInline(block.contentNodes),
};
case "paragraph":
default:
return {
type: "paragraph",
attrs: commonAttrs,
content: textNodesToInline(block.contentNodes),
};
}
}
function extractInlineContent(node: TiptapNode | undefined): EditorContentNode[] {
if (!node) return [];
if (Array.isArray(node.content)) {
return node.content.flatMap((child) => {
if (child.type === "text") {
const text = toText(child.text);
if (!text) return [];
return [{ type: "text" as const, text, marks: readMarks(child.marks) }];
}
if (child.type === "hardBreak") {
return [{ type: "text" as const, text: "\n", marks: [] }];
}
return extractInlineContent(child);
});
}
if (node.type === "text") {
const text = toText(node.text);
return text ? [{ type: "text", text, marks: readMarks(node.marks) }] : [];
}
return [];
}
function firstChild(node: TiptapNode | undefined): TiptapNode | undefined {
return Array.isArray(node?.content) ? node?.content?.[0] : undefined;
}
function normalizeDocumentId(value: unknown, fallback = "document"): string {
const raw = typeof value === "string" ? value.trim() : "";
return raw || fallback;
}
function nodeBlockId(node: TiptapNode, index: number): string {
return normalizeBlockId(node.attrs?.[BLOCK_ID_ATTR], `block-${index + 1}`);
}
function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {
const blockId = nodeBlockId(node, index);
switch (node.type) {
case "paragraph":
return { blockId, blockType: "paragraph", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };
case "heading":
return {
blockId,
blockType: "heading",
props: { headingLevel: clampHeadingLevel(node.attrs?.level) },
contentNodes: extractInlineContent(node),
childBlockIds: [],
};
case "bulletList": {
const paragraph = firstChild(firstChild(node));
return {
blockId,
blockType: "bullet_list_item",
props: {},
contentNodes: extractInlineContent(paragraph),
childBlockIds: [],
};
}
case "orderedList": {
const paragraph = firstChild(firstChild(node));
return {
blockId,
blockType: "numbered_list_item",
props: {},
contentNodes: extractInlineContent(paragraph),
childBlockIds: [],
};
}
case "taskList": {
const taskItem = firstChild(node);
const paragraph = firstChild(taskItem);
return {
blockId,
blockType: "todo",
props: { checked: Boolean(taskItem?.attrs?.checked) },
contentNodes: extractInlineContent(paragraph),
childBlockIds: [],
};
}
case "blockquote":
return { blockId, blockType: "quote", props: {}, contentNodes: extractInlineContent(firstChild(node)), childBlockIds: [] };
case "codeBlock":
return {
blockId,
blockType: "code_block",
props: { language: typeof node.attrs?.language === "string" ? String(node.attrs.language) : null },
contentNodes: extractInlineContent(node),
childBlockIds: [],
};
default:
return null;
}
}
export function editorBlockDocumentFromContent(content: Json): EditorBlockDocument {
return normalizeInputToEditorDocument(content);
}
export function normalizeEditorBlockDocument(
value: unknown,
fallbackDocumentId?: string,
): EditorBlockDocument {
const normalized = normalizeInputToEditorDocument(value as Json);
const documentId = normalizeDocumentId(
fallbackDocumentId ?? normalized.documentId,
normalized.documentId,
);
const blockIdSet = new Set(normalized.blocks.map((block) => block.blockId));
const rootBlockIds = Array.from(
new Set(
normalized.rootBlockIds
.map((id, index) => normalizeBlockId(id, normalized.blocks[index]?.blockId ?? `block-${index + 1}`))
.filter((id) => blockIdSet.has(id)),
),
);
return {
...normalized,
documentId,
rootBlockIds: rootBlockIds.length > 0 ? rootBlockIds : normalized.blocks.map((block) => block.blockId),
};
}
export function tiptapDocFromEditorBlockDocument(document: EditorBlockDocument): TiptapDoc {
const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));
const nodes = document.rootBlockIds
.map((blockId) => blockById.get(blockId))
.filter((block): block is EditorBlock => Boolean(block))
.map(blockToTiptapNode);
return {
type: "doc",
content: nodes.length > 0 ? nodes : EMPTY_DOC.content,
};
}
export function editorBlockDocumentFromTiptapDoc(doc: unknown, documentId = "document"): EditorBlockDocument {
if (!doc || typeof doc !== "object") {
return { documentId, rootBlockIds: [], blocks: [] };
}
const root = doc as { type?: unknown; content?: unknown };
if (root.type !== "doc" || !Array.isArray(root.content)) {
return { documentId, rootBlockIds: [], blocks: [] };
}
const blocks = root.content.flatMap((node, index) => {
if (!node || typeof node !== "object") return [];
const block = tiptapNodeToBlock(node as TiptapNode, index);
return block ? [block] : [];
});
return {
documentId,
rootBlockIds: blocks.map((block) => block.blockId),
blocks,
};
}
function orderedBlocksForLegacy(document: EditorBlockDocument): EditorBlock[] {
const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));
const visited = new Set<string>();
const ordered: EditorBlock[] = [];
const visit = (blockId: string) => {
if (visited.has(blockId)) return;
visited.add(blockId);
const block = blockById.get(blockId);
if (!block) return;
ordered.push(block);
for (const childId of block.childBlockIds ?? []) {
visit(childId);
}
};
for (const rootId of document.rootBlockIds) {
visit(rootId);
}
for (const block of document.blocks) {
visit(block.blockId);
}
return ordered;
}
export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocument): Json {
return orderedBlocksForLegacy(document).map((block) => ({
id: block.blockId,
type: block.blockType,
props:
block.blockType === "heading"
? { level: block.props?.headingLevel ?? 1 }
: block.blockType === "todo"
? { checked: Boolean(block.props?.checked) }
: block.blockType === "code_block"
? { language: block.props?.language ?? null }
: undefined,
content: (block.contentNodes ?? []).map((node) => node.text).join(""),
}));
}
export function tiptapDocFromBlocks(blocks: Json): TiptapDoc {
return tiptapDocFromEditorBlockDocument(editorBlockDocumentFromContent(blocks));
}
export function blocksFromTiptapDoc(doc: unknown): Json {
return legacyBlocksFromEditorBlockDocument(editorBlockDocumentFromTiptapDoc(doc));
}
export const blocksToTiptapDoc = tiptapDocFromBlocks;
export const tiptapDocToBlocks = blocksFromTiptapDoc;
export const tiptapDocumentToEditorBlockDocumentAdapter = editorBlockDocumentFromTiptapDoc;
export const editorBlockDocumentToLegacyBlocksAdapter = legacyBlocksFromEditorBlockDocument;
+16 -36
View File
@@ -32,6 +32,14 @@ export type MnoteRuntimeConfig = {
* mnoteWebBaseUrl
*/
mnoteWebTreeShellEnabled?: boolean;
/**
* host
* host BlockNote
*/
documentEditorHost?:
| "blocknote"
| "leptos_tiptap_inline"
| "leptos_tiptap_iframe_debug";
/**
* Electron
*/
@@ -184,13 +192,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.isDesktop ??
(typeof window === "undefined" ? process.env.MNOTE_DESKTOP === "1" : false);
// 说明:`onlyofficeBaseUrl` / `onlyofficeStorageHostOverride` 属于“通用默认值”。
// 但我们在 `public/mnote-env.json` 里同时提供了 Web/Desktop 两套配置,
// 因此这里应当优先选择与平台匹配的字段,避免被环境变量中的默认值覆盖。
//
// 典型场景:开发机环境变量仍是 `http://localhost:8081`,但网页端需要走
// `https://onlyoffice.<域名>`。若不调整优先级,远程浏览器会尝试访问它自己
// 的 localhost,从而导致 docx 打不开。
const onlyofficeBaseUrl = isDesktop
? cfg.onlyofficeBaseUrlDesktop ||
cfg.onlyofficeBaseUrl ||
@@ -201,9 +202,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.onlyofficeBaseUrlDesktop ||
"";
// 说明:这里需要用 `??` 而不是 `||`,允许通过配置显式传入空字符串来“关闭 override”。
// 否则 Web 端配置为 "" 时,会被环境变量里的默认值(例如 host.docker.internal)误覆盖,
// 进而导致 ONLYOFFICE 文档服务器无法访问真实的存储地址。
const onlyofficeStorageHostOverride = isDesktop
? (cfg.onlyofficeStorageHostOverrideDesktop ??
cfg.onlyofficeStorageHostOverride ??
@@ -214,26 +212,13 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
cfg.onlyofficeStorageHostOverrideDesktop ??
"");
// 说明:`onlyofficeProxyOrigin` 也属于“通用默认值”。在 Web 端需要优先使用
// onlyofficeProxyOriginWeb,避免被旧的通用值(例如 Cloudflare 域名)覆盖,
// 否则会导致 ONLYOFFICE 文档服务器回源到错误的公网入口。
const onlyofficeProxyOrigin = isDesktop
? cfg.onlyofficeProxyOrigin || ""
: cfg.onlyofficeProxyOriginWeb ||
cfg.onlyofficeProxyOrigin ||
"";
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeProxyOriginWeb)
: (cfg.onlyofficeProxyOriginWeb ?? cfg.onlyofficeProxyOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
// 说明:ONLYOFFICE 回调(保存)必须是“文档服务器可访问”的地址。
// Web 端优先用 onlyofficeCallbackOriginWeb(通常是 http://host.docker.internal:3000)。
const onlyofficeCallbackOrigin = isDesktop
? cfg.onlyofficeCallbackOriginDesktop ||
cfg.onlyofficeCallbackOrigin ||
cfg.onlyofficeCallbackOriginWeb ||
""
: cfg.onlyofficeCallbackOriginWeb ||
cfg.onlyofficeCallbackOrigin ||
cfg.onlyofficeCallbackOriginDesktop ||
"";
? (cfg.onlyofficeCallbackOriginDesktop ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginWeb)
: (cfg.onlyofficeCallbackOriginWeb ?? cfg.onlyofficeCallbackOrigin ?? cfg.onlyofficeCallbackOriginDesktop);
const mnoteWebBaseUrl = (cfg.mnoteWebBaseUrl ?? "").trim().replace(/\/+$/, "");
const mnoteWebTreeShellEnabled =
@@ -251,7 +236,7 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
};
};
export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
export function getMnoteRuntimeConfig(): MnoteRuntimeConfig {
if (typeof window !== "undefined") {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
}
@@ -272,13 +257,8 @@ export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
...envRuntime,
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
...(publicRuntime.mnoteWebBaseUrl !== undefined
? { mnoteWebBaseUrl: publicRuntime.mnoteWebBaseUrl }
: {}),
...(publicRuntime.mnoteWebTreeShellEnabled !== undefined
? { mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled }
: {}),
isDesktop,
mnoteWebTreeShellEnabled:
publicRuntime.mnoteWebTreeShellEnabled ?? envRuntime.mnoteWebTreeShellEnabled,
};
return normalizeRuntimeConfig(merged);
};
}