Fix tiptap selection sync and toolbar event loop

This commit is contained in:
lix-2026
2026-04-19 21:03:25 +08:00
parent 111a87d4fd
commit 394e2a155c
87 changed files with 17415 additions and 527 deletions
+1
View File
@@ -53,6 +53,7 @@ design
# Rust 本地构建与调试产物 # Rust 本地构建与调试产物
/rust/target/ /rust/target/
/rust/spikes/**/target/
# Harness 本地运行状态/调试产物 # Harness 本地运行状态/调试产物
/.harness-active /.harness-active
@@ -0,0 +1,6 @@
- generic [ref=e2]:
- heading "Example Domain" [level=1] [ref=e3]
- paragraph [ref=e4]: This domain is for use in documentation examples without needing permission. Avoid use in operations.
- paragraph [ref=e5]:
- link "Learn more" [ref=e6] [cursor=pointer]:
- /url: https://iana.org/domains/example
@@ -0,0 +1,5 @@
- generic [active] [ref=e1]:
- generic [ref=e3]:
- img [ref=e4]
- generic [ref=e7]: Connecting...
- alert [ref=e8]
@@ -0,0 +1,85 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- generic [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- button "Bold" [ref=e38] [cursor=pointer]
- button "Italic" [ref=e39] [cursor=pointer]
- button "Strike" [ref=e40] [cursor=pointer]
- button "Code" [ref=e41] [cursor=pointer]
- button "Highlight" [ref=e42] [cursor=pointer]
- button "Link" [ref=e43] [cursor=pointer]
- button "Turn into" [ref=e45] [cursor=pointer]
- generic [ref=e46]:
- button "Undo" [ref=e47] [cursor=pointer]
- button "Redo" [ref=e48] [cursor=pointer]
- textbox [ref=e50]:
- heading "准备接管主编辑器体验" [level=1] [ref=e51]
- paragraph [ref=e52]:
- text: 这页现在先只在
- strong [ref=e53]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e54]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e55]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e56]:
- listitem [ref=e57]:
- paragraph [ref=e58]: 标题、段落、引用、代码块
- listitem [ref=e59]:
- paragraph [ref=e60]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e61]:
- paragraph [ref=e62]: 分割线、行内样式、链接和高亮
- paragraph [ref=e63]:
- text: 请把光标放到这里,然后按
- code [ref=e64]: /
- text:
- generic [ref=e65]:
- generic [ref=e66]:
- text: 当前 P0 边界:
- code [ref=e67]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e68]:
- text: 运行时扩展不再走
- code [ref=e69]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e70]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e71]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e72] [cursor=pointer]:
- generic [ref=e73]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e74]: 展开 / 收起
@@ -0,0 +1,85 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- generic [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- button "Bold" [ref=e38] [cursor=pointer]
- button "Italic" [ref=e39] [cursor=pointer]
- button "Strike" [ref=e40] [cursor=pointer]
- button "Code" [ref=e41] [cursor=pointer]
- button "Highlight" [ref=e42] [cursor=pointer]
- button "Link" [ref=e43] [cursor=pointer]
- button "Turn into" [ref=e45] [cursor=pointer]
- generic [ref=e46]:
- button "Undo" [ref=e47] [cursor=pointer]
- button "Redo" [ref=e48] [cursor=pointer]
- textbox [ref=e50]:
- heading "准备接管主编辑器体验" [level=1] [ref=e51]
- paragraph [ref=e52]:
- text: 这页现在先只在
- strong [ref=e53]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e54]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e55]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e56]:
- listitem [ref=e57]:
- paragraph [ref=e58]: 标题、段落、引用、代码块
- listitem [ref=e59]:
- paragraph [ref=e60]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e61]:
- paragraph [ref=e62]: 分割线、行内样式、链接和高亮
- paragraph [ref=e63]:
- text: 请把光标放到这里,然后按
- code [ref=e64]: /
- text:
- generic [ref=e65]:
- generic [ref=e66]:
- text: 当前 P0 边界:
- code [ref=e67]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e68]:
- text: 运行时扩展不再走
- code [ref=e69]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e70]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e71]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e72] [cursor=pointer]:
- generic [ref=e73]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e74]: 展开 / 收起
@@ -0,0 +1,232 @@
- generic [active] [ref=e1]:
- alert [ref=e8]
- generic [ref=e9]:
- banner [ref=e10]:
- generic [ref=e11]:
- group [ref=e12]:
- group [ref=e13]:
- button "Undo" [disabled] [ref=e14]:
- img [ref=e15]
- group [ref=e17]:
- button "Redo" [disabled] [ref=e18]:
- img [ref=e19]
- separator [ref=e21]
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- separator [ref=e33]
- button [ref=e34] [cursor=pointer]
- generic [ref=e40]:
- generic [ref=e41]:
- paragraph [ref=e42]: Write, type '/' for commands…
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e43]:
- text: Welcome to Notion-like template
- img "sparkles emoji" [ref=e45]
- blockquote [ref=e46]:
- paragraph [ref=e47]:
- img "love_letter emoji" [ref=e49]
- strong [ref=e50]: Invite your colleagues to make this fun!
- text: Just copy the URL from your browser and share it everyone with the link can join in and collaborate in real time.
- generic:
- generic: John Anderson
- paragraph [ref=e51]:
- text: Start writing your thoughts here …
- img "pencil2 emoji" [ref=e53]
- text: Try some
- strong [ref=e54]: "Markdown:"
- paragraph [ref=e55]: /
- code [ref=e57]: "# Headings - Lists > Quotes `Inline code`"
- paragraph [ref=e58]:
- text: Or type
- code [ref=e59]: /
- text: to open the command menu and discover blocks, formatting, and hidden features.
- separator [ref=e61]
- heading "Make it yours" [level=1] [ref=e62]
- list [ref=e63]:
- listitem [ref=e64]:
- paragraph [ref=e65]:
- strong [ref=e66]: Select text
- text: "to reveal a floating toolbar:"
- emphasis [ref=e67]: Quickly italicize,
- emphasis [ref=e69]: color
- emphasis [ref=e70]: ", add"
- link "links" [ref=e71]:
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
- emphasis [ref=e72]: links
- emphasis [ref=e73]:
- text: ", or"
- mark [ref=e74]: highlight text
- text: just as you're used to..
- listitem [ref=e75]:
- paragraph [ref=e76]:
- strong [ref=e77]: Hover near any block
- text: to reveal the context handle
- code [ref=e78]:
- emphasis [ref=e79]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
- listitem [ref=e80]:
- paragraph [ref=e81]:
- text: Mention teammates with
- code [ref=e82]: "@"
- text: and add some fun with emoji
- code [ref=e83]: ":"
- img "partying_face emoji" [ref=e85]
- listitem [ref=e86]:
- paragraph [ref=e87]:
- text: Switch between
- img "sunny emoji" [ref=e89]
- text: light and
- img "new_moon emoji" [ref=e91]
- text: dark mode whatever fits your mood.
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e96] [cursor=pointer]
- blockquote [ref=e97]:
- paragraph [ref=e98]:
- strong [ref=e99]: Need a spark?
- img "sparkles emoji" [ref=e101]
- strong [ref=e102]
- text: Summon the AI Assistant with
- code [ref=e103]: /ask ai
- text: from the context menu, or by selecting text and choosing
- code [ref=e104]: Improve
- text: .
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e105]:
- img "white_check_mark emoji" [ref=e107]
- strong [ref=e108]
- text: Interactive Tables Included
- paragraph [ref=e109]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
- table [ref=e113]:
- rowgroup [ref=e119]:
- row "Name Role Department Location" [ref=e120]:
- columnheader "Name" [ref=e121]:
- paragraph [ref=e122]: Name
- columnheader "Role" [ref=e123]:
- paragraph [ref=e124]: Role
- columnheader "Department" [ref=e125]:
- paragraph [ref=e126]: Department
- columnheader "Location" [ref=e127]:
- paragraph [ref=e128]: Location
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e129]:
- cell "Alice Johnson" [ref=e130]:
- paragraph [ref=e131]: Alice Johnson
- cell "Senior Developer" [ref=e132]:
- paragraph [ref=e133]: Senior Developer
- cell "Engineering" [ref=e134]:
- paragraph [ref=e135]: Engineering
- cell "San Francisco" [ref=e136]:
- paragraph [ref=e137]: San Francisco
- row "Bob Smith Product Manager Product New York" [ref=e138]:
- cell "Bob Smith" [ref=e139]:
- paragraph [ref=e140]: Bob Smith
- cell "Product Manager" [ref=e141]:
- paragraph [ref=e142]: Product Manager
- cell "Product" [ref=e143]:
- paragraph [ref=e144]: Product
- cell "New York" [ref=e145]:
- paragraph [ref=e146]: New York
- row "Carol White UX Designer Design London" [ref=e147]:
- cell "Carol White" [ref=e148]:
- paragraph [ref=e149]: Carol White
- cell "UX Designer" [ref=e150]:
- paragraph [ref=e151]: UX Designer
- cell "Design" [ref=e152]:
- paragraph [ref=e153]: Design
- cell "London" [ref=e154]:
- paragraph [ref=e155]: London
- row "David Chen Data Analyst Analytics Remote" [ref=e156]:
- cell "David Chen" [ref=e157]:
- paragraph [ref=e158]: David Chen
- cell "Data Analyst" [ref=e159]:
- paragraph [ref=e160]: Data Analyst
- cell "Analytics" [ref=e161]:
- paragraph [ref=e162]: Analytics
- cell "Remote" [ref=e163]:
- paragraph [ref=e164]: Remote
- heading "Table of Content" [level=2] [ref=e165]
- generic [ref=e167]:
- generic [ref=e168]: Table of contents
- navigation "Table of contents" [ref=e169]:
- link "Welcome to Notion-like template" [ref=e170] [cursor=pointer]:
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours" [ref=e171] [cursor=pointer]:
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included" [ref=e172] [cursor=pointer]:
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content" [ref=e173] [cursor=pointer]:
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist" [ref=e174] [cursor=pointer]:
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart" [ref=e175] [cursor=pointer]:
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description" [ref=e176] [cursor=pointer]:
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S." [ref=e177] [cursor=pointer]:
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
- heading "Checklist" [level=2] [ref=e178]
- list [ref=e179]:
- listitem [ref=e180]:
- generic [ref=e181]:
- checkbox "Task item checkbox for Read up to this point" [checked]
- paragraph [ref=e184]: Read up to this point
- listitem [ref=e185]:
- generic [ref=e186]:
- checkbox "Task item checkbox for Try a slash command"
- paragraph [ref=e189]: Try a slash command
- listitem [ref=e190]:
- generic [ref=e191]:
- checkbox "Task item checkbox for Mention someone"
- paragraph [ref=e194]: Mention someone
- listitem [ref=e195]:
- generic [ref=e196]:
- checkbox "Task item checkbox for Use the floating toolbar"
- paragraph [ref=e199]: Use the floating toolbar
- listitem [ref=e200]:
- generic [ref=e201]:
- checkbox "Task item checkbox for Add a color highlight"
- paragraph [ref=e204]: Add a color highlight
- listitem [ref=e205]:
- generic [ref=e206]:
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
- paragraph [ref=e209]: Explore the context menu & drag blocks
- listitem [ref=e210]:
- generic [ref=e211]:
- checkbox "Task item checkbox for Ask the AI for help"
- paragraph [ref=e214]: Ask the AI for help
- separator [ref=e216]
- heading "Developer quickstart" [level=1] [ref=e217]
- paragraph [ref=e218]: "Ready to build your own editor? Just run:"
- code [ref=e220]: npx @tiptap/cli init
- blockquote [ref=e221]:
- paragraph [ref=e222]:
- strong [ref=e223]: Did you know?
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools like the AI Assistant, advanced color palettes, or context menus are exclusive to paid users.
- text: Unlock even more possibilities by
- link "upgrading your plan" [ref=e224]:
- /url: https://tiptap.dev/pricing
- text: "!"
- heading "Short description" [level=3] [ref=e225]
- list [ref=e226]:
- listitem [ref=e227]:
- paragraph [ref=e228]: Content blocks = Node Components
- listitem [ref=e229]:
- paragraph [ref=e230]: Toolbars, menus, and buttons = UI Components
- separator [ref=e232]
- heading "P.S." [level=2] [ref=e233]
- paragraph [ref=e234]: Youre using the Notion-like template, available for paid users.
- generic [ref=e236]:
- navigation "Table of contents":
- generic:
- link "Welcome to Notion-like template":
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours":
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included":
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content":
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist":
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart":
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description":
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S.":
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
@@ -0,0 +1,85 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- generic [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- button "Bold" [ref=e38] [cursor=pointer]
- button "Italic" [ref=e39] [cursor=pointer]
- button "Strike" [ref=e40] [cursor=pointer]
- button "Code" [ref=e41] [cursor=pointer]
- button "Highlight" [ref=e42] [cursor=pointer]
- button "Link" [ref=e43] [cursor=pointer]
- button "Turn into" [ref=e45] [cursor=pointer]
- generic [ref=e46]:
- button "Undo" [ref=e47] [cursor=pointer]
- button "Redo" [ref=e48] [cursor=pointer]
- textbox [ref=e50]:
- heading "准备接管主编辑器体验" [level=1] [ref=e51]
- paragraph [ref=e52]:
- text: 这页现在先只在
- strong [ref=e53]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e54]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e55]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e56]:
- listitem [ref=e57]:
- paragraph [ref=e58]: 标题、段落、引用、代码块
- listitem [ref=e59]:
- paragraph [ref=e60]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e61]:
- paragraph [ref=e62]: 分割线、行内样式、链接和高亮
- paragraph [ref=e63]:
- text: 请把光标放到这里,然后按
- code [ref=e64]: /
- text:
- generic [ref=e65]:
- generic [ref=e66]:
- text: 当前 P0 边界:
- code [ref=e67]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e68]:
- text: 运行时扩展不再走
- code [ref=e69]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e70]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e71]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e72] [cursor=pointer]:
- generic [ref=e73]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e74]: 展开 / 收起
@@ -0,0 +1,232 @@
- generic [active] [ref=e1]:
- alert [ref=e8]
- generic [ref=e9]:
- banner [ref=e10]:
- generic [ref=e11]:
- group [ref=e12]:
- group [ref=e13]:
- button "Undo" [disabled] [ref=e14]:
- img [ref=e15]
- group [ref=e17]:
- button "Redo" [disabled] [ref=e18]:
- img [ref=e19]
- separator [ref=e21]
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- separator [ref=e33]
- button [ref=e34] [cursor=pointer]
- generic [ref=e40]:
- generic [ref=e41]:
- paragraph [ref=e42]: Write, type '/' for commands…
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e43]:
- text: Welcome to Notion-like template
- img "sparkles emoji" [ref=e45]
- blockquote [ref=e46]:
- paragraph [ref=e47]:
- img "love_letter emoji" [ref=e49]
- strong [ref=e50]: Invite your colleagues to make this fun!
- text: Just copy the URL from your browser and share it everyone with the link can join in and collaborate in real time.
- generic:
- generic: John Anderson
- paragraph [ref=e51]:
- text: Start writing your thoughts here …
- img "pencil2 emoji" [ref=e53]
- text: Try some
- strong [ref=e54]: "Markdown:"
- paragraph [ref=e55]: /
- code [ref=e57]: "# Headings - Lists > Quotes `Inline code`"
- paragraph [ref=e58]:
- text: Or type
- code [ref=e59]: /
- text: to open the command menu and discover blocks, formatting, and hidden features.
- separator [ref=e61]
- heading "Make it yours" [level=1] [ref=e62]
- list [ref=e63]:
- listitem [ref=e64]:
- paragraph [ref=e65]:
- strong [ref=e66]: Select text
- text: "to reveal a floating toolbar:"
- emphasis [ref=e67]: Quickly italicize,
- emphasis [ref=e69]: color
- emphasis [ref=e70]: ", add"
- link "links" [ref=e71]:
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
- emphasis [ref=e72]: links
- emphasis [ref=e73]:
- text: ", or"
- mark [ref=e74]: highlight text
- text: just as you're used to..
- listitem [ref=e75]:
- paragraph [ref=e76]:
- strong [ref=e77]: Hover near any block
- text: to reveal the context handle
- code [ref=e78]:
- emphasis [ref=e79]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
- listitem [ref=e80]:
- paragraph [ref=e81]:
- text: Mention teammates with
- code [ref=e82]: "@"
- text: and add some fun with emoji
- code [ref=e83]: ":"
- img "partying_face emoji" [ref=e85]
- listitem [ref=e86]:
- paragraph [ref=e87]:
- text: Switch between
- img "sunny emoji" [ref=e89]
- text: light and
- img "new_moon emoji" [ref=e91]
- text: dark mode whatever fits your mood.
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e96] [cursor=pointer]
- blockquote [ref=e97]:
- paragraph [ref=e98]:
- strong [ref=e99]: Need a spark?
- img "sparkles emoji" [ref=e101]
- strong [ref=e102]
- text: Summon the AI Assistant with
- code [ref=e103]: /ask ai
- text: from the context menu, or by selecting text and choosing
- code [ref=e104]: Improve
- text: .
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e105]:
- img "white_check_mark emoji" [ref=e107]
- strong [ref=e108]
- text: Interactive Tables Included
- paragraph [ref=e109]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
- table [ref=e113]:
- rowgroup [ref=e119]:
- row "Name Role Department Location" [ref=e120]:
- columnheader "Name" [ref=e121]:
- paragraph [ref=e122]: Name
- columnheader "Role" [ref=e123]:
- paragraph [ref=e124]: Role
- columnheader "Department" [ref=e125]:
- paragraph [ref=e126]: Department
- columnheader "Location" [ref=e127]:
- paragraph [ref=e128]: Location
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e129]:
- cell "Alice Johnson" [ref=e130]:
- paragraph [ref=e131]: Alice Johnson
- cell "Senior Developer" [ref=e132]:
- paragraph [ref=e133]: Senior Developer
- cell "Engineering" [ref=e134]:
- paragraph [ref=e135]: Engineering
- cell "San Francisco" [ref=e136]:
- paragraph [ref=e137]: San Francisco
- row "Bob Smith Product Manager Product New York" [ref=e138]:
- cell "Bob Smith" [ref=e139]:
- paragraph [ref=e140]: Bob Smith
- cell "Product Manager" [ref=e141]:
- paragraph [ref=e142]: Product Manager
- cell "Product" [ref=e143]:
- paragraph [ref=e144]: Product
- cell "New York" [ref=e145]:
- paragraph [ref=e146]: New York
- row "Carol White UX Designer Design London" [ref=e147]:
- cell "Carol White" [ref=e148]:
- paragraph [ref=e149]: Carol White
- cell "UX Designer" [ref=e150]:
- paragraph [ref=e151]: UX Designer
- cell "Design" [ref=e152]:
- paragraph [ref=e153]: Design
- cell "London" [ref=e154]:
- paragraph [ref=e155]: London
- row "David Chen Data Analyst Analytics Remote" [ref=e156]:
- cell "David Chen" [ref=e157]:
- paragraph [ref=e158]: David Chen
- cell "Data Analyst" [ref=e159]:
- paragraph [ref=e160]: Data Analyst
- cell "Analytics" [ref=e161]:
- paragraph [ref=e162]: Analytics
- cell "Remote" [ref=e163]:
- paragraph [ref=e164]: Remote
- heading "Table of Content" [level=2] [ref=e165]
- generic [ref=e167]:
- generic [ref=e168]: Table of contents
- navigation "Table of contents" [ref=e169]:
- link "Welcome to Notion-like template" [ref=e170] [cursor=pointer]:
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours" [ref=e171] [cursor=pointer]:
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included" [ref=e172] [cursor=pointer]:
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content" [ref=e173] [cursor=pointer]:
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist" [ref=e174] [cursor=pointer]:
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart" [ref=e175] [cursor=pointer]:
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description" [ref=e176] [cursor=pointer]:
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S." [ref=e177] [cursor=pointer]:
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
- heading "Checklist" [level=2] [ref=e178]
- list [ref=e179]:
- listitem [ref=e246]:
- generic [ref=e247]:
- checkbox "Task item checkbox for Read up to this point" [checked]
- paragraph [ref=e250]: Read up to this point
- listitem [ref=e251]:
- generic [ref=e252]:
- checkbox "Task item checkbox for Try a slash command"
- paragraph [ref=e255]: Try a slash command
- listitem [ref=e256]:
- generic [ref=e257]:
- checkbox "Task item checkbox for Mention someone"
- paragraph [ref=e260]: Mention someone
- listitem [ref=e261]:
- generic [ref=e262]:
- checkbox "Task item checkbox for Use the floating toolbar"
- paragraph [ref=e265]: Use the floating toolbar
- listitem [ref=e266]:
- generic [ref=e267]:
- checkbox "Task item checkbox for Add a color highlight"
- paragraph [ref=e270]: Add a color highlight
- listitem [ref=e271]:
- generic [ref=e272]:
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
- paragraph [ref=e275]: Explore the context menu & drag blocks
- listitem [ref=e276]:
- generic [ref=e277]:
- checkbox "Task item checkbox for Ask the AI for help"
- paragraph [ref=e280]: Ask the AI for help
- separator [ref=e216]
- heading "Developer quickstart" [level=1] [ref=e217]
- paragraph [ref=e218]: "Ready to build your own editor? Just run:"
- code [ref=e220]: npx @tiptap/cli init
- blockquote [ref=e221]:
- paragraph [ref=e222]:
- strong [ref=e223]: Did you know?
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools like the AI Assistant, advanced color palettes, or context menus are exclusive to paid users.
- text: Unlock even more possibilities by
- link "upgrading your plan" [ref=e224]:
- /url: https://tiptap.dev/pricing
- text: "!"
- heading "Short description" [level=3] [ref=e225]
- list [ref=e226]:
- listitem [ref=e227]:
- paragraph [ref=e228]: Content blocks = Node Components
- listitem [ref=e229]:
- paragraph [ref=e230]: Toolbars, menus, and buttons = UI Components
- separator [ref=e232]
- heading "P.S." [level=2] [ref=e233]
- paragraph [ref=e234]: Youre using the Notion-like template, available for paid users.
- generic [ref=e236]:
- navigation "Table of contents":
- generic:
- link "Welcome to Notion-like template":
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours":
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included":
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content":
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist":
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart":
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description":
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S.":
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
@@ -0,0 +1,85 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- generic [ref=e35]:
- generic [ref=e36]:
- generic [ref=e37]:
- button "Bold" [ref=e38] [cursor=pointer]
- button "Italic" [ref=e39] [cursor=pointer]
- button "Strike" [ref=e40] [cursor=pointer]
- button "Code" [ref=e41] [cursor=pointer]
- button "Highlight" [ref=e42] [cursor=pointer]
- button "Link" [ref=e43] [cursor=pointer]
- button "Turn into" [ref=e45] [cursor=pointer]
- generic [ref=e46]:
- button "Undo" [ref=e47] [cursor=pointer]
- button "Redo" [ref=e48] [cursor=pointer]
- textbox [ref=e50]:
- heading "准备接管主编辑器体验" [level=1] [ref=e51]
- paragraph [ref=e52]:
- text: 这页现在先只在
- strong [ref=e53]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e54]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e55]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e56]:
- listitem [ref=e57]:
- paragraph [ref=e58]: 标题、段落、引用、代码块
- listitem [ref=e59]:
- paragraph [ref=e60]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e61]:
- paragraph [ref=e62]: 分割线、行内样式、链接和高亮
- paragraph [ref=e63]:
- text: 请把光标放到这里,然后按
- code [ref=e64]: /
- text:
- generic [ref=e65]:
- generic [ref=e66]:
- text: 当前 P0 边界:
- code [ref=e67]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e68]:
- text: 运行时扩展不再走
- code [ref=e69]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e70]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e71]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e72] [cursor=pointer]:
- generic [ref=e73]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e74]: 展开 / 收起
@@ -0,0 +1,262 @@
- generic [ref=e1]:
- alert [ref=e8]
- generic [ref=e9]:
- banner [ref=e10]:
- generic [ref=e11]:
- group [ref=e12]:
- group [ref=e13]:
- button "Undo" [ref=e14] [cursor=pointer]:
- img [ref=e15]
- group [ref=e17]:
- button "Redo" [disabled] [ref=e18]:
- img [ref=e19]
- separator [ref=e21]
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- separator [ref=e33]
- button [ref=e34] [cursor=pointer]
- generic [ref=e40]:
- generic [active] [ref=e41]:
- paragraph [ref=e42]
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e43]:
- text: Welcome to Notion-like template
- img "sparkles emoji" [ref=e45]
- blockquote [ref=e46]:
- paragraph [ref=e47]:
- img "love_letter emoji" [ref=e49]
- strong [ref=e50]: Invite your colleagues to make this fun!
- text: Just copy the URL from your browser and share it everyone with the link can join in and collaborate in real time.
- generic:
- generic: John Anderson
- paragraph [ref=e51]:
- text: Start writing your thoughts here …
- img "pencil2 emoji" [ref=e53]
- text: Try some
- strong [ref=e54]: "Markdown:"
- paragraph [ref=e285]: /
- paragraph [ref=e55]: /
- code [ref=e57]: "# Headings - Lists > Quotes `Inline code`"
- paragraph [ref=e58]:
- text: Or type
- code [ref=e59]: /
- text: to open the command menu and discover blocks, formatting, and hidden features.
- separator [ref=e61]
- heading "Make it yours" [level=1] [ref=e62]
- list [ref=e63]:
- listitem [ref=e64]:
- paragraph [ref=e65]:
- strong [ref=e66]: Select text
- text: "to reveal a floating toolbar:"
- emphasis [ref=e67]: Quickly italicize,
- emphasis [ref=e69]: color
- emphasis [ref=e70]: ", add"
- link "links" [ref=e71]:
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
- emphasis [ref=e72]: links
- emphasis [ref=e73]:
- text: ", or"
- mark [ref=e74]: highlight text
- text: just as you're used to..
- listitem [ref=e75]:
- paragraph [ref=e76]:
- strong [ref=e77]: Hover near any block
- text: to reveal the context handle
- code [ref=e78]:
- emphasis [ref=e79]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
- listitem [ref=e80]:
- paragraph [ref=e81]:
- text: Mention teammates with
- code [ref=e82]: "@"
- text: and add some fun with emoji
- code [ref=e83]: ":"
- img "partying_face emoji" [ref=e85]
- listitem [ref=e86]:
- paragraph [ref=e87]:
- text: Switch between
- img "sunny emoji" [ref=e89]
- text: light and
- img "new_moon emoji" [ref=e91]
- text: dark mode whatever fits your mood.
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e96] [cursor=pointer]
- blockquote [ref=e97]:
- paragraph [ref=e98]:
- strong [ref=e99]: Need a spark?
- img "sparkles emoji" [ref=e101]
- strong [ref=e102]
- text: Summon the AI Assistant with
- code [ref=e103]: /ask ai
- text: from the context menu, or by selecting text and choosing
- code [ref=e104]: Improve
- text: .
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e105]:
- img "white_check_mark emoji" [ref=e107]
- strong [ref=e108]
- text: Interactive Tables Included
- paragraph [ref=e109]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
- table [ref=e113]:
- rowgroup [ref=e119]:
- row "Name Role Department Location" [ref=e120]:
- columnheader "Name" [ref=e121]:
- paragraph [ref=e122]: Name
- columnheader "Role" [ref=e123]:
- paragraph [ref=e124]: Role
- columnheader "Department" [ref=e125]:
- paragraph [ref=e126]: Department
- columnheader "Location" [ref=e127]:
- paragraph [ref=e128]: Location
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e129]:
- cell "Alice Johnson" [ref=e130]:
- paragraph [ref=e131]: Alice Johnson
- cell "Senior Developer" [ref=e132]:
- paragraph [ref=e133]: Senior Developer
- cell "Engineering" [ref=e134]:
- paragraph [ref=e135]: Engineering
- cell "San Francisco" [ref=e136]:
- paragraph [ref=e137]: San Francisco
- row "Bob Smith Product Manager Product New York" [ref=e138]:
- cell "Bob Smith" [ref=e139]:
- paragraph [ref=e140]: Bob Smith
- cell "Product Manager" [ref=e141]:
- paragraph [ref=e142]: Product Manager
- cell "Product" [ref=e143]:
- paragraph [ref=e144]: Product
- cell "New York" [ref=e145]:
- paragraph [ref=e146]: New York
- row "Carol White UX Designer Design London" [ref=e147]:
- cell "Carol White" [ref=e148]:
- paragraph [ref=e149]: Carol White
- cell "UX Designer" [ref=e150]:
- paragraph [ref=e151]: UX Designer
- cell "Design" [ref=e152]:
- paragraph [ref=e153]: Design
- cell "London" [ref=e154]:
- paragraph [ref=e155]: London
- row "David Chen Data Analyst Analytics Remote" [ref=e156]:
- cell "David Chen" [ref=e157]:
- paragraph [ref=e158]: David Chen
- cell "Data Analyst" [ref=e159]:
- paragraph [ref=e160]: Data Analyst
- cell "Analytics" [ref=e161]:
- paragraph [ref=e162]: Analytics
- cell "Remote" [ref=e163]:
- paragraph [ref=e164]: Remote
- heading "Table of Content" [level=2] [ref=e165]
- generic [ref=e167]:
- generic [ref=e168]: Table of contents
- navigation "Table of contents" [ref=e169]:
- link "Welcome to Notion-like template" [ref=e170] [cursor=pointer]:
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours" [ref=e171] [cursor=pointer]:
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included" [ref=e172] [cursor=pointer]:
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content" [ref=e173] [cursor=pointer]:
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist" [ref=e174] [cursor=pointer]:
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart" [ref=e175] [cursor=pointer]:
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description" [ref=e176] [cursor=pointer]:
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S." [ref=e177] [cursor=pointer]:
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
- heading "Checklist" [level=2] [ref=e178]
- list [ref=e179]:
- listitem [ref=e246]:
- generic [ref=e247]:
- checkbox "Task item checkbox for Read up to this point" [checked]
- paragraph [ref=e250]: Read up to this point
- listitem [ref=e251]:
- generic [ref=e252]:
- checkbox "Task item checkbox for Try a slash command"
- paragraph [ref=e255]: Try a slash command
- listitem [ref=e256]:
- generic [ref=e257]:
- checkbox "Task item checkbox for Mention someone"
- paragraph [ref=e260]: Mention someone
- listitem [ref=e261]:
- generic [ref=e262]:
- checkbox "Task item checkbox for Use the floating toolbar"
- paragraph [ref=e265]: Use the floating toolbar
- listitem [ref=e266]:
- generic [ref=e267]:
- checkbox "Task item checkbox for Add a color highlight"
- paragraph [ref=e270]: Add a color highlight
- listitem [ref=e271]:
- generic [ref=e272]:
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
- paragraph [ref=e275]: Explore the context menu & drag blocks
- listitem [ref=e276]:
- generic [ref=e277]:
- checkbox "Task item checkbox for Ask the AI for help"
- paragraph [ref=e280]: Ask the AI for help
- separator [ref=e216]
- heading "Developer quickstart" [level=1] [ref=e217]
- paragraph [ref=e218]: "Ready to build your own editor? Just run:"
- code [ref=e220]: npx @tiptap/cli init
- blockquote [ref=e221]:
- paragraph [ref=e222]:
- strong [ref=e223]: Did you know?
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools like the AI Assistant, advanced color palettes, or context menus are exclusive to paid users.
- text: Unlock even more possibilities by
- link "upgrading your plan" [ref=e224]:
- /url: https://tiptap.dev/pricing
- text: "!"
- heading "Short description" [level=3] [ref=e225]
- list [ref=e226]:
- listitem [ref=e227]:
- paragraph [ref=e228]: Content blocks = Node Components
- listitem [ref=e229]:
- paragraph [ref=e230]: Toolbars, menus, and buttons = UI Components
- separator [ref=e232]
- heading "P.S." [level=2] [ref=e233]
- paragraph [ref=e234]: Youre using the Notion-like template, available for paid users.
- toolbar "toolbar" [ref=e414]:
- group [ref=e415]:
- button "Improve" [ref=e416] [cursor=pointer]:
- img [ref=e417]
- generic [ref=e420]: Improve
- group [ref=e421]:
- 'button "Turn into (current: Text)" [ref=e422] [cursor=pointer]':
- generic [ref=e423]: Text
- img [ref=e424]
- group [ref=e426]:
- button "Bold" [ref=e427] [cursor=pointer]:
- img [ref=e428]
- button "Italic" [ref=e430] [cursor=pointer]:
- img [ref=e431]
- button "Underline" [ref=e433] [cursor=pointer]:
- img [ref=e434]
- button "Strike" [ref=e436] [cursor=pointer]:
- img [ref=e437]
- button "Code" [ref=e440] [cursor=pointer]:
- img [ref=e441]
- group [ref=e445]:
- button "Link" [ref=e446] [cursor=pointer]:
- img [ref=e447]
- button "Text color" [ref=e450] [cursor=pointer]:
- img [ref=e452]
- img [ref=e454]
- group [ref=e456]:
- button [ref=e457] [cursor=pointer]:
- img [ref=e458]
- generic [ref=e236]:
- navigation "Table of contents":
- generic:
- link "Welcome to Notion-like template":
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours":
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included":
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content":
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist":
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart":
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description":
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S.":
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
@@ -0,0 +1,280 @@
- generic [ref=e1]:
- alert [ref=e8]
- generic [ref=e9]:
- banner [ref=e10]:
- generic [ref=e11]:
- group [ref=e12]:
- group [ref=e13]:
- button "Undo" [ref=e14] [cursor=pointer]:
- img [ref=e15]
- group [ref=e17]:
- button "Redo" [disabled] [ref=e18]:
- img [ref=e19]
- separator [ref=e21]
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- separator [ref=e33]
- button [ref=e34] [cursor=pointer]
- generic [ref=e40]:
- generic [ref=e41]:
- paragraph [ref=e42]
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e43]:
- text: Welcome to Notion-like template
- img "sparkles emoji" [ref=e45]
- blockquote [ref=e46]:
- paragraph [ref=e47]:
- img "love_letter emoji" [ref=e49]
- strong [ref=e50]: Invite your colleagues to make this fun!
- text: Just copy the URL from your browser and share it everyone with the link can join in and collaborate in real time.
- generic:
- generic: John Anderson
- paragraph [ref=e51]:
- text: Start writing your thoughts here …
- img "pencil2 emoji" [ref=e53]
- text: Try some
- strong [ref=e54]: "Markdown:"
- paragraph [ref=e285]: /
- paragraph [ref=e55]: /
- code [ref=e57]: "# Headings - Lists > Quotes `Inline code`"
- paragraph [ref=e58]:
- text: Or type
- code [ref=e59]: /
- text: to open the command menu and discover blocks, formatting, and hidden features.
- separator [ref=e61]
- heading "Make it yours" [level=1] [ref=e62]
- list [ref=e63]:
- listitem [ref=e64]:
- paragraph [ref=e65]:
- strong [ref=e66]: Select text
- text: "to reveal a floating toolbar:"
- emphasis [ref=e67]: Quickly italicize,
- emphasis [ref=e69]: color
- emphasis [ref=e70]: ", add"
- link "links" [ref=e71]:
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
- emphasis [ref=e72]: links
- emphasis [ref=e73]:
- text: ", or"
- mark [ref=e74]: highlight text
- text: just as you're used to..
- listitem [ref=e75]:
- paragraph [ref=e76]:
- strong [ref=e77]: Hover near any block
- text: to reveal the context handle
- code [ref=e78]:
- emphasis [ref=e79]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
- listitem [ref=e80]:
- paragraph [ref=e81]:
- text: Mention teammates with
- code [ref=e82]: "@"
- text: and add some fun with emoji
- code [ref=e83]: ":"
- img "partying_face emoji" [ref=e85]
- listitem [ref=e86]:
- paragraph [ref=e87]:
- text: Switch between
- img "sunny emoji" [ref=e89]
- text: light and
- img "new_moon emoji" [ref=e91]
- text: dark mode whatever fits your mood.
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e96] [cursor=pointer]
- blockquote [ref=e97]:
- paragraph [ref=e98]:
- strong [ref=e99]: Need a spark?
- img "sparkles emoji" [ref=e101]
- strong [ref=e102]
- text: Summon the AI Assistant with
- code [ref=e103]: /ask ai
- text: from the context menu, or by selecting text and choosing
- code [ref=e104]: Improve
- text: .
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e105]:
- img "white_check_mark emoji" [ref=e107]
- strong [ref=e108]
- text: Interactive Tables Included
- paragraph [ref=e109]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
- table [ref=e113]:
- rowgroup [ref=e119]:
- row "Name Role Department Location" [ref=e120]:
- columnheader "Name" [ref=e121]:
- paragraph [ref=e122]: Name
- columnheader "Role" [ref=e123]:
- paragraph [ref=e124]: Role
- columnheader "Department" [ref=e125]:
- paragraph [ref=e126]: Department
- columnheader "Location" [ref=e127]:
- paragraph [ref=e128]: Location
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e129]:
- cell "Alice Johnson" [ref=e130]:
- paragraph [ref=e131]: Alice Johnson
- cell "Senior Developer" [ref=e132]:
- paragraph [ref=e133]: Senior Developer
- cell "Engineering" [ref=e134]:
- paragraph [ref=e135]: Engineering
- cell "San Francisco" [ref=e136]:
- paragraph [ref=e137]: San Francisco
- row "Bob Smith Product Manager Product New York" [ref=e138]:
- cell "Bob Smith" [ref=e139]:
- paragraph [ref=e140]: Bob Smith
- cell "Product Manager" [ref=e141]:
- paragraph [ref=e142]: Product Manager
- cell "Product" [ref=e143]:
- paragraph [ref=e144]: Product
- cell "New York" [ref=e145]:
- paragraph [ref=e146]: New York
- row "Carol White UX Designer Design London" [ref=e147]:
- cell "Carol White" [ref=e148]:
- paragraph [ref=e149]: Carol White
- cell "UX Designer" [ref=e150]:
- paragraph [ref=e151]: UX Designer
- cell "Design" [ref=e152]:
- paragraph [ref=e153]: Design
- cell "London" [ref=e154]:
- paragraph [ref=e155]: London
- row "David Chen Data Analyst Analytics Remote" [ref=e156]:
- cell "David Chen" [ref=e157]:
- paragraph [ref=e158]: David Chen
- cell "Data Analyst" [ref=e159]:
- paragraph [ref=e160]: Data Analyst
- cell "Analytics" [ref=e161]:
- paragraph [ref=e162]: Analytics
- cell "Remote" [ref=e163]:
- paragraph [ref=e164]: Remote
- heading "Table of Content" [level=2] [ref=e165]
- generic [ref=e167]:
- generic [ref=e168]: Table of contents
- navigation "Table of contents" [ref=e169]:
- link "Welcome to Notion-like template" [ref=e170] [cursor=pointer]:
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours" [ref=e171] [cursor=pointer]:
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included" [ref=e172] [cursor=pointer]:
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content" [ref=e173] [cursor=pointer]:
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist" [ref=e174] [cursor=pointer]:
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart" [ref=e175] [cursor=pointer]:
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description" [ref=e176] [cursor=pointer]:
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S." [ref=e177] [cursor=pointer]:
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
- heading "Checklist" [level=2] [ref=e178]
- list [ref=e179]:
- listitem [ref=e246]:
- generic [ref=e247]:
- checkbox "Task item checkbox for Read up to this point" [checked]
- paragraph [ref=e250]: Read up to this point
- listitem [ref=e251]:
- generic [ref=e252]:
- checkbox "Task item checkbox for Try a slash command"
- paragraph [ref=e255]: Try a slash command
- listitem [ref=e256]:
- generic [ref=e257]:
- checkbox "Task item checkbox for Mention someone"
- paragraph [ref=e260]: Mention someone
- listitem [ref=e261]:
- generic [ref=e262]:
- checkbox "Task item checkbox for Use the floating toolbar"
- paragraph [ref=e265]: Use the floating toolbar
- listitem [ref=e266]:
- generic [ref=e267]:
- checkbox "Task item checkbox for Add a color highlight"
- paragraph [ref=e270]: Add a color highlight
- listitem [ref=e271]:
- generic [ref=e272]:
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
- paragraph [ref=e275]: Explore the context menu & drag blocks
- listitem [ref=e276]:
- generic [ref=e277]:
- checkbox "Task item checkbox for Ask the AI for help"
- paragraph [ref=e280]: Ask the AI for help
- separator [ref=e216]
- heading "Developer quickstart" [level=1] [ref=e217]
- paragraph [ref=e218]: "Ready to build your own editor? Just run:"
- code [ref=e220]: npx @tiptap/cli init
- blockquote [ref=e221]:
- paragraph [ref=e222]:
- strong [ref=e223]: Did you know?
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools like the AI Assistant, advanced color palettes, or context menus are exclusive to paid users.
- text: Unlock even more possibilities by
- link "upgrading your plan" [ref=e224]:
- /url: https://tiptap.dev/pricing
- text: "!"
- heading "Short description" [level=3] [ref=e225]
- list [ref=e226]:
- listitem [ref=e227]:
- paragraph [ref=e228]: Content blocks = Node Components
- listitem [ref=e229]:
- paragraph [ref=e230]: Toolbars, menus, and buttons = UI Components
- separator [ref=e232]
- heading "P.S." [level=2] [ref=e233]
- paragraph [ref=e234]: Youre using the Notion-like template, available for paid users.
- generic [ref=e287]:
- button "Insert block" [ref=e288] [cursor=pointer]:
- img [ref=e289]
- button [expanded]:
- img
- dialog [ref=e467]:
- combobox [expanded] [active] [ref=e468]
- listbox [ref=e469]:
- generic [ref=e470]: Text
- group [ref=e471]:
- option "Color" [ref=e472] [cursor=pointer]:
- img [ref=e473]
- generic [ref=e476]: Color
- img [ref=e477]
- option "Turn Into" [ref=e479] [cursor=pointer]:
- img [ref=e480]
- generic [ref=e482]: Turn Into
- img [ref=e483]
- option "Reset formatting" [ref=e485] [cursor=pointer]:
- img [ref=e486]
- generic [ref=e488]: Reset formatting
- separator [ref=e489]
- group [ref=e490]:
- option "Duplicate node ModD" [ref=e491] [cursor=pointer]:
- img [ref=e492]
- generic [ref=e495]: Duplicate node
- generic [ref=e496]: ModD
- option "Copy to clipboard ModC" [ref=e497] [cursor=pointer]:
- img [ref=e498]
- generic [ref=e500]: Copy to clipboard
- generic [ref=e501]: ModC
- option "Copy anchor link ModCtrlL" [ref=e502] [cursor=pointer]:
- img [ref=e503]
- generic [ref=e506]: Copy anchor link
- generic [ref=e507]: ModCtrlL
- separator [ref=e508]
- group [ref=e509]:
- option "Ask AI ModJ" [ref=e510] [cursor=pointer]:
- img [ref=e511]
- generic [ref=e514]: Ask AI
- generic [ref=e515]: ModJ
- separator [ref=e516]
- group [ref=e517]:
- option "Delete Backspace" [ref=e518] [cursor=pointer]:
- img [ref=e519]
- generic [ref=e521]: Delete
- generic [ref=e522]: Backspace
- generic [ref=e236]:
- navigation "Table of contents":
- generic:
- link "Welcome to Notion-like template":
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours":
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included":
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content":
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist":
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart":
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description":
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S.":
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
@@ -0,0 +1,280 @@
- generic [ref=e1]:
- alert [ref=e8]
- generic [ref=e9]:
- banner [ref=e10]:
- generic [ref=e11]:
- group [ref=e12]:
- group [ref=e13]:
- button "Undo" [ref=e14] [cursor=pointer]:
- img [ref=e15]
- group [ref=e17]:
- button "Redo" [disabled] [ref=e18]:
- img [ref=e19]
- separator [ref=e21]
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- separator [ref=e33]
- button [ref=e34] [cursor=pointer]
- generic [ref=e40]:
- generic [ref=e41]:
- paragraph [ref=e42]
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e43]:
- text: Welcome to Notion-like template
- img "sparkles emoji" [ref=e45]
- blockquote [ref=e46]:
- paragraph [ref=e47]:
- img "love_letter emoji" [ref=e49]
- strong [ref=e50]: Invite your colleagues to make this fun!
- text: Just copy the URL from your browser and share it everyone with the link can join in and collaborate in real time.
- generic:
- generic: John Anderson
- paragraph [ref=e51]:
- text: Start writing your thoughts here …
- img "pencil2 emoji" [ref=e53]
- text: Try some
- strong [ref=e54]: "Markdown:"
- paragraph [ref=e285]: /
- paragraph [ref=e55]: /
- code [ref=e57]: "# Headings - Lists > Quotes `Inline code`"
- paragraph [ref=e58]:
- text: Or type
- code [ref=e59]: /
- text: to open the command menu and discover blocks, formatting, and hidden features.
- separator [ref=e61]
- heading "Make it yours" [level=1] [ref=e62]
- list [ref=e63]:
- listitem [ref=e64]:
- paragraph [ref=e65]:
- strong [ref=e66]: Select text
- text: "to reveal a floating toolbar:"
- emphasis [ref=e67]: Quickly italicize,
- emphasis [ref=e69]: color
- emphasis [ref=e70]: ", add"
- link "links" [ref=e71]:
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
- emphasis [ref=e72]: links
- emphasis [ref=e73]:
- text: ", or"
- mark [ref=e74]: highlight text
- text: just as you're used to..
- listitem [ref=e75]:
- paragraph [ref=e76]:
- strong [ref=e77]: Hover near any block
- text: to reveal the context handle
- code [ref=e78]:
- emphasis [ref=e79]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
- listitem [ref=e80]:
- paragraph [ref=e81]:
- text: Mention teammates with
- code [ref=e82]: "@"
- text: and add some fun with emoji
- code [ref=e83]: ":"
- img "partying_face emoji" [ref=e85]
- listitem [ref=e86]:
- paragraph [ref=e87]:
- text: Switch between
- img "sunny emoji" [ref=e89]
- text: light and
- img "new_moon emoji" [ref=e91]
- text: dark mode whatever fits your mood.
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e96] [cursor=pointer]
- blockquote [ref=e97]:
- paragraph [ref=e98]:
- strong [ref=e99]: Need a spark?
- img "sparkles emoji" [ref=e101]
- strong [ref=e102]
- text: Summon the AI Assistant with
- code [ref=e103]: /ask ai
- text: from the context menu, or by selecting text and choosing
- code [ref=e104]: Improve
- text: .
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e105]:
- img "white_check_mark emoji" [ref=e107]
- strong [ref=e108]
- text: Interactive Tables Included
- paragraph [ref=e109]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
- table [ref=e113]:
- rowgroup [ref=e119]:
- row "Name Role Department Location" [ref=e120]:
- columnheader "Name" [ref=e121]:
- paragraph [ref=e122]: Name
- columnheader "Role" [ref=e123]:
- paragraph [ref=e124]: Role
- columnheader "Department" [ref=e125]:
- paragraph [ref=e126]: Department
- columnheader "Location" [ref=e127]:
- paragraph [ref=e128]: Location
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e129]:
- cell "Alice Johnson" [ref=e130]:
- paragraph [ref=e131]: Alice Johnson
- cell "Senior Developer" [ref=e132]:
- paragraph [ref=e133]: Senior Developer
- cell "Engineering" [ref=e134]:
- paragraph [ref=e135]: Engineering
- cell "San Francisco" [ref=e136]:
- paragraph [ref=e137]: San Francisco
- row "Bob Smith Product Manager Product New York" [ref=e138]:
- cell "Bob Smith" [ref=e139]:
- paragraph [ref=e140]: Bob Smith
- cell "Product Manager" [ref=e141]:
- paragraph [ref=e142]: Product Manager
- cell "Product" [ref=e143]:
- paragraph [ref=e144]: Product
- cell "New York" [ref=e145]:
- paragraph [ref=e146]: New York
- row "Carol White UX Designer Design London" [ref=e147]:
- cell "Carol White" [ref=e148]:
- paragraph [ref=e149]: Carol White
- cell "UX Designer" [ref=e150]:
- paragraph [ref=e151]: UX Designer
- cell "Design" [ref=e152]:
- paragraph [ref=e153]: Design
- cell "London" [ref=e154]:
- paragraph [ref=e155]: London
- row "David Chen Data Analyst Analytics Remote" [ref=e156]:
- cell "David Chen" [ref=e157]:
- paragraph [ref=e158]: David Chen
- cell "Data Analyst" [ref=e159]:
- paragraph [ref=e160]: Data Analyst
- cell "Analytics" [ref=e161]:
- paragraph [ref=e162]: Analytics
- cell "Remote" [ref=e163]:
- paragraph [ref=e164]: Remote
- heading "Table of Content" [level=2] [ref=e165]
- generic [ref=e167]:
- generic [ref=e168]: Table of contents
- navigation "Table of contents" [ref=e169]:
- link "Welcome to Notion-like template" [ref=e170] [cursor=pointer]:
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours" [ref=e171] [cursor=pointer]:
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included" [ref=e172] [cursor=pointer]:
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content" [ref=e173] [cursor=pointer]:
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist" [ref=e174] [cursor=pointer]:
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart" [ref=e175] [cursor=pointer]:
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description" [ref=e176] [cursor=pointer]:
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S." [ref=e177] [cursor=pointer]:
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
- heading "Checklist" [level=2] [ref=e178]
- list [ref=e179]:
- listitem [ref=e246]:
- generic [ref=e247]:
- checkbox "Task item checkbox for Read up to this point" [checked]
- paragraph [ref=e250]: Read up to this point
- listitem [ref=e251]:
- generic [ref=e252]:
- checkbox "Task item checkbox for Try a slash command"
- paragraph [ref=e255]: Try a slash command
- listitem [ref=e256]:
- generic [ref=e257]:
- checkbox "Task item checkbox for Mention someone"
- paragraph [ref=e260]: Mention someone
- listitem [ref=e261]:
- generic [ref=e262]:
- checkbox "Task item checkbox for Use the floating toolbar"
- paragraph [ref=e265]: Use the floating toolbar
- listitem [ref=e266]:
- generic [ref=e267]:
- checkbox "Task item checkbox for Add a color highlight"
- paragraph [ref=e270]: Add a color highlight
- listitem [ref=e271]:
- generic [ref=e272]:
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
- paragraph [ref=e275]: Explore the context menu & drag blocks
- listitem [ref=e276]:
- generic [ref=e277]:
- checkbox "Task item checkbox for Ask the AI for help"
- paragraph [ref=e280]: Ask the AI for help
- separator [ref=e216]
- heading "Developer quickstart" [level=1] [ref=e217]
- paragraph [ref=e218]: "Ready to build your own editor? Just run:"
- code [ref=e220]: npx @tiptap/cli init
- blockquote [ref=e221]:
- paragraph [ref=e222]:
- strong [ref=e223]: Did you know?
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools like the AI Assistant, advanced color palettes, or context menus are exclusive to paid users.
- text: Unlock even more possibilities by
- link "upgrading your plan" [ref=e224]:
- /url: https://tiptap.dev/pricing
- text: "!"
- heading "Short description" [level=3] [ref=e225]
- list [ref=e226]:
- listitem [ref=e227]:
- paragraph [ref=e228]: Content blocks = Node Components
- listitem [ref=e229]:
- paragraph [ref=e230]: Toolbars, menus, and buttons = UI Components
- separator [ref=e232]
- heading "P.S." [level=2] [ref=e233]
- paragraph [ref=e234]: Youre using the Notion-like template, available for paid users.
- generic [ref=e287]:
- button "Insert block" [ref=e288] [cursor=pointer]:
- img [ref=e289]
- button [expanded]:
- img
- dialog [ref=e467]:
- combobox [expanded] [active] [ref=e468]
- listbox [ref=e469]:
- generic [ref=e470]: Text
- group [ref=e471]:
- option "Color" [ref=e472] [cursor=pointer]:
- img [ref=e473]
- generic [ref=e476]: Color
- img [ref=e477]
- option "Turn Into" [ref=e479] [cursor=pointer]:
- img [ref=e480]
- generic [ref=e482]: Turn Into
- img [ref=e483]
- option "Reset formatting" [ref=e485] [cursor=pointer]:
- img [ref=e486]
- generic [ref=e488]: Reset formatting
- separator [ref=e489]
- group [ref=e490]:
- option "Duplicate node ModD" [ref=e491] [cursor=pointer]:
- img [ref=e492]
- generic [ref=e495]: Duplicate node
- generic [ref=e496]: ModD
- option "Copy to clipboard ModC" [ref=e497] [cursor=pointer]:
- img [ref=e498]
- generic [ref=e500]: Copy to clipboard
- generic [ref=e501]: ModC
- option "Copy anchor link ModCtrlL" [ref=e502] [cursor=pointer]:
- img [ref=e503]
- generic [ref=e506]: Copy anchor link
- generic [ref=e507]: ModCtrlL
- separator [ref=e508]
- group [ref=e509]:
- option "Ask AI ModJ" [ref=e510] [cursor=pointer]:
- img [ref=e511]
- generic [ref=e514]: Ask AI
- generic [ref=e515]: ModJ
- separator [ref=e516]
- group [ref=e517]:
- option "Delete Backspace" [ref=e518] [cursor=pointer]:
- img [ref=e519]
- generic [ref=e521]: Delete
- generic [ref=e522]: Backspace
- generic [ref=e236]:
- navigation "Table of contents":
- generic:
- link "Welcome to Notion-like template":
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours":
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included":
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content":
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist":
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart":
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description":
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S.":
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
@@ -0,0 +1,72 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- textbox [ref=e37]:
- heading "准备接管主编辑器体验" [level=1] [ref=e38]
- paragraph [ref=e39]:
- text: 这页现在先只在
- strong [ref=e40]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e41]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e42]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e43]:
- listitem [ref=e44]:
- paragraph [ref=e45]: 标题、段落、引用、代码块
- listitem [ref=e46]:
- paragraph [ref=e47]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e48]:
- paragraph [ref=e49]: 分割线、行内样式、链接和高亮
- paragraph [ref=e50]:
- text: 请把光标放到这里,然后按
- code [ref=e51]: /
- text:
- generic [ref=e52]:
- generic [ref=e53]:
- text: 当前 P0 边界:
- code [ref=e54]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e55]:
- text: 运行时扩展不再走
- code [ref=e56]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e57]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e58]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e59] [cursor=pointer]:
- generic [ref=e60]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e61]: 展开 / 收起
@@ -0,0 +1,5 @@
- generic [active] [ref=e1]:
- generic [ref=e3]:
- img [ref=e4]
- generic [ref=e7]: Connecting...
- alert [ref=e8]
@@ -0,0 +1,77 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- generic [ref=e35]:
- generic [ref=e62]:
- button "Insert block" [ref=e63] [cursor=pointer]:
- generic [ref=e64]: +
- button "Open block menu" [ref=e65]: ⠿
- textbox [ref=e37]:
- heading "准备接管主编辑器体验" [level=1] [ref=e38]
- paragraph [ref=e39]:
- text: 这页现在先只在
- strong [ref=e40]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e41]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e42]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e43]:
- listitem [ref=e44]:
- paragraph [ref=e45]: 标题、段落、引用、代码块
- listitem [ref=e46]:
- paragraph [ref=e47]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e48]:
- paragraph [ref=e49]: 分割线、行内样式、链接和高亮
- paragraph [ref=e50]:
- text: 请把光标放到这里,然后按
- code [ref=e51]: /
- text:
- generic [ref=e52]:
- generic [ref=e53]:
- text: 当前 P0 边界:
- code [ref=e54]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e55]:
- text: 运行时扩展不再走
- code [ref=e56]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e57]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e58]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e59] [cursor=pointer]:
- generic [ref=e60]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e61]: 展开 / 收起
@@ -0,0 +1,238 @@
- generic [active] [ref=e1]:
- alert [ref=e8]
- generic [ref=e9]:
- banner [ref=e10]:
- generic [ref=e11]:
- group [ref=e12]:
- group [ref=e13]:
- button "Undo" [disabled] [ref=e14]:
- img [ref=e15]
- group [ref=e17]:
- button "Redo" [disabled] [ref=e18]:
- img [ref=e19]
- separator [ref=e21]
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- separator [ref=e33]
- button [ref=e34] [cursor=pointer]
- generic [ref=e40]:
- generic [ref=e41]:
- paragraph [ref=e42]: Write, type '/' for commands…
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e43]:
- text: Welcome to Notion-like template
- img "sparkles emoji" [ref=e45]
- blockquote [ref=e46]:
- paragraph [ref=e47]:
- img "love_letter emoji" [ref=e49]
- strong [ref=e50]: Invite your colleagues to make this fun!
- text: Just copy the URL from your browser and share it everyone with the link can join in and collaborate in real time.
- generic:
- generic: John Anderson
- paragraph [ref=e51]:
- text: Start writing your thoughts here …
- img "pencil2 emoji" [ref=e53]
- text: Try some
- strong [ref=e54]: "Markdown:"
- paragraph [ref=e55]: /
- paragraph [ref=e56]: /
- code [ref=e58]: "# Headings - Lists > Quotes `Inline code`"
- paragraph [ref=e59]:
- text: Or type
- code [ref=e60]: /
- text: to open the command menu and discover blocks, formatting, and hidden features.
- separator [ref=e62]
- heading "Make it yours" [level=1] [ref=e63]
- list [ref=e64]:
- listitem [ref=e65]:
- paragraph [ref=e66]:
- strong [ref=e67]: Select text
- text: "to reveal a floating toolbar:"
- emphasis [ref=e68]: Quickly italicize,
- emphasis [ref=e70]: color
- emphasis [ref=e71]: ", add"
- link "links" [ref=e72]:
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
- emphasis [ref=e73]: links
- emphasis [ref=e74]:
- text: ", or"
- mark [ref=e75]: highlight text
- text: just as you're used to..
- listitem [ref=e76]:
- paragraph [ref=e77]:
- strong [ref=e78]: Hover near any block
- text: to reveal the context handle
- code [ref=e79]:
- emphasis [ref=e80]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
- listitem [ref=e81]:
- paragraph [ref=e82]:
- text: Mention teammates with
- code [ref=e83]: "@"
- text: and add some fun with emoji
- code [ref=e84]: ":"
- img "partying_face emoji" [ref=e86]
- listitem [ref=e87]:
- paragraph [ref=e88]:
- text: Switch between
- img "sunny emoji" [ref=e90]
- text: light and
- img "new_moon emoji" [ref=e92]
- text: dark mode whatever fits your mood.
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e97] [cursor=pointer]
- blockquote [ref=e98]:
- paragraph [ref=e99]:
- strong [ref=e100]: Need a spark?
- img "sparkles emoji" [ref=e102]
- strong [ref=e103]
- text: Summon the AI Assistant with
- code [ref=e104]: /ask ai
- text: from the context menu, or by selecting text and choosing
- code [ref=e105]: Improve
- text: .
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e106]:
- img "white_check_mark emoji" [ref=e108]
- strong [ref=e109]
- text: Interactive Tables Included
- paragraph [ref=e110]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
- table [ref=e114]:
- rowgroup [ref=e120]:
- row "Name Role Department Location" [ref=e121]:
- columnheader "Name" [ref=e122]:
- paragraph [ref=e123]: Name
- columnheader "Role" [ref=e124]:
- paragraph [ref=e125]: Role
- columnheader "Department" [ref=e126]:
- paragraph [ref=e127]: Department
- columnheader "Location" [ref=e128]:
- paragraph [ref=e129]: Location
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e130]:
- cell "Alice Johnson" [ref=e131]:
- paragraph [ref=e132]: Alice Johnson
- cell "Senior Developer" [ref=e133]:
- paragraph [ref=e134]: Senior Developer
- cell "Engineering" [ref=e135]:
- paragraph [ref=e136]: Engineering
- cell "San Francisco" [ref=e137]:
- paragraph [ref=e138]: San Francisco
- row "Bob Smith Product Manager Product New York" [ref=e139]:
- cell "Bob Smith" [ref=e140]:
- paragraph [ref=e141]: Bob Smith
- cell "Product Manager" [ref=e142]:
- paragraph [ref=e143]: Product Manager
- cell "Product" [ref=e144]:
- paragraph [ref=e145]: Product
- cell "New York" [ref=e146]:
- paragraph [ref=e147]: New York
- row "Carol White UX Designer Design London" [ref=e148]:
- cell "Carol White" [ref=e149]:
- paragraph [ref=e150]: Carol White
- cell "UX Designer" [ref=e151]:
- paragraph [ref=e152]: UX Designer
- cell "Design" [ref=e153]:
- paragraph [ref=e154]: Design
- cell "London" [ref=e155]:
- paragraph [ref=e156]: London
- row "David Chen Data Analyst Analytics Remote" [ref=e157]:
- cell "David Chen" [ref=e158]:
- paragraph [ref=e159]: David Chen
- cell "Data Analyst" [ref=e160]:
- paragraph [ref=e161]: Data Analyst
- cell "Analytics" [ref=e162]:
- paragraph [ref=e163]: Analytics
- cell "Remote" [ref=e164]:
- paragraph [ref=e165]: Remote
- heading "Table of Content" [level=2] [ref=e166]
- generic [ref=e168]:
- generic [ref=e169]: Table of contents
- navigation "Table of contents" [ref=e170]:
- link "Welcome to Notion-like template" [ref=e171] [cursor=pointer]:
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours" [ref=e172] [cursor=pointer]:
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included" [ref=e173] [cursor=pointer]:
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content" [ref=e174] [cursor=pointer]:
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist" [ref=e175] [cursor=pointer]:
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart" [ref=e176] [cursor=pointer]:
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description" [ref=e177] [cursor=pointer]:
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S." [ref=e178] [cursor=pointer]:
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
- heading "Checklist" [level=2] [ref=e179]
- list [ref=e180]:
- listitem [ref=e249]:
- generic [ref=e250]:
- checkbox "Task item checkbox for Read up to this point" [checked]
- paragraph [ref=e253]: Read up to this point
- listitem [ref=e254]:
- generic [ref=e255]:
- checkbox "Task item checkbox for Try a slash command"
- paragraph [ref=e258]: Try a slash command
- listitem [ref=e259]:
- generic [ref=e260]:
- checkbox "Task item checkbox for Mention someone"
- paragraph [ref=e263]: Mention someone
- listitem [ref=e264]:
- generic [ref=e265]:
- checkbox "Task item checkbox for Use the floating toolbar"
- paragraph [ref=e268]: Use the floating toolbar
- listitem [ref=e269]:
- generic [ref=e270]:
- checkbox "Task item checkbox for Add a color highlight"
- paragraph [ref=e273]: Add a color highlight
- listitem [ref=e274]:
- generic [ref=e275]:
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
- paragraph [ref=e278]: Explore the context menu & drag blocks
- listitem [ref=e279]:
- generic [ref=e280]:
- checkbox "Task item checkbox for Ask the AI for help"
- paragraph [ref=e283]: Ask the AI for help
- separator [ref=e217]
- heading "Developer quickstart" [level=1] [ref=e218]
- paragraph [ref=e219]: "Ready to build your own editor? Just run:"
- code [ref=e221]: npx @tiptap/cli init
- blockquote [ref=e222]:
- paragraph [ref=e223]:
- strong [ref=e224]: Did you know?
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools like the AI Assistant, advanced color palettes, or context menus are exclusive to paid users.
- text: Unlock even more possibilities by
- link "upgrading your plan" [ref=e225]:
- /url: https://tiptap.dev/pricing
- text: "!"
- heading "Short description" [level=3] [ref=e226]
- list [ref=e227]:
- listitem [ref=e228]:
- paragraph [ref=e229]: Content blocks = Node Components
- listitem [ref=e230]:
- paragraph [ref=e231]: Toolbars, menus, and buttons = UI Components
- separator [ref=e233]
- heading "P.S." [level=2] [ref=e234]
- paragraph [ref=e235]: Youre using the Notion-like template, available for paid users.
- generic [ref=e289]:
- button "Insert block" [ref=e290] [cursor=pointer]:
- img [ref=e291]
- button [ref=e293]:
- img [ref=e294]
- generic [ref=e237]:
- navigation "Table of contents":
- generic:
- link "Welcome to Notion-like template":
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours":
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included":
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content":
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist":
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart":
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description":
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S.":
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
@@ -0,0 +1,77 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:段落 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- generic [ref=e35]:
- generic [ref=e62]:
- button "Insert block" [ref=e63] [cursor=pointer]:
- generic [ref=e64]: +
- button "Open block menu" [ref=e65]: ⠿
- textbox [active] [ref=e37]:
- heading "准备接管主编辑器体验" [level=1] [ref=e38]
- paragraph [ref=e39]:
- text: 这页现在先只在
- strong [ref=e40]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e41]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e42]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e43]:
- listitem [ref=e44]:
- paragraph [ref=e45]: 标题、段落、引用、代码块
- listitem [ref=e46]:
- paragraph [ref=e47]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e48]:
- paragraph [ref=e49]: 分割线、行内样式、链接和高亮
- paragraph [ref=e50]:
- text: 请把光标放到这里,然后按
- code [ref=e51]: /
- text:
- generic [ref=e52]:
- generic [ref=e53]:
- text: 当前 P0 边界:
- code [ref=e54]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e55]:
- text: 运行时扩展不再走
- code [ref=e56]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e57]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e58]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e59] [cursor=pointer]:
- generic [ref=e60]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e61]: 展开 / 收起
@@ -0,0 +1,72 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:段落 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- textbox [active] [ref=e37]:
- heading "准备接管主编辑器体验" [level=1] [ref=e38]
- paragraph [ref=e39]:
- text: 这页现在先只在
- strong [ref=e40]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e41]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e42]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e43]:
- listitem [ref=e44]:
- paragraph [ref=e45]: 标题、段落、引用、代码块
- listitem [ref=e46]:
- paragraph [ref=e47]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e48]:
- paragraph [ref=e49]: 分割线、行内样式、链接和高亮
- paragraph [ref=e50]:
- text: 请把光标放到这里,然后按
- code [ref=e51]: /
- text:
- generic [ref=e52]:
- generic [ref=e53]:
- text: 当前 P0 边界:
- code [ref=e54]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e55]:
- text: 运行时扩展不再走
- code [ref=e56]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e57]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e58]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e59] [cursor=pointer]:
- generic [ref=e60]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e61]: 展开 / 收起
@@ -0,0 +1,78 @@
- generic [active] [ref=e1]:
- generic [ref=e3]:
- generic [ref=e4]:
- img [ref=e6]
- text: Build failure
- generic [ref=e8]: "error from build pipeline Caused by: 0: HTML build pipeline failed (1 errors), showing first 1: error from asset pipeline 2: running cargo build 3: error during cargo build execution 4: cargo call to executable 'cargo' with args: '[\"build\", \"--target=wasm32-unknown-unknown\", \"--manifest-path\", \"/mnt/Data1T/mnote/rust/spikes/leptos-tiptap-spike/Cargo.toml\"]' returned a bad status: exit status: 101"
- main [ref=e9]:
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e13]
- paragraph [ref=e14]:
- text: 这里继续只做
- code [ref=e15]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e16]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e17]:
- generic [ref=e18]:
- generic [ref=e19]: 变更计数
- strong [ref=e20]: "0"
- generic [ref=e21]:
- generic [ref=e22]: Todo Schema
- strong [ref=e23]: pending
- generic [ref=e24]:
- generic [ref=e25]: Selection
- strong [ref=e26]: 当前块:段落 | 行内样式:无行内样式
- generic [ref=e27]:
- generic [ref=e28]:
- generic [ref=e29]:
- generic [ref=e30]: 空间 / 主文档 /
- strong [ref=e31]: Editor Baseline Reset
- generic [ref=e32]:
- generic [ref=e33]: 反馈:
- strong [ref=e34]: 编辑器已准备就绪,并已恢复本地草稿
- textbox "Spike 页面标题" [ref=e35]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e36]:
- generic [ref=e37]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e38]: 选中文本时会出现最小浮动工具条
- generic [ref=e39]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e40]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e41]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- textbox [ref=e44]:
- heading "准备接管主编辑器体验" [level=1] [ref=e45]
- paragraph [ref=e46]:
- text: 这页现在先只在
- strong [ref=e47]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e48]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e49]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e50]:
- listitem [ref=e51]:
- paragraph [ref=e52]: 标题、段落、引用、代码块
- listitem [ref=e53]:
- paragraph [ref=e54]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e55]:
- paragraph [ref=e56]: 分割线、行内样式、链接和高亮
- paragraph [ref=e57]:
- text: 请把光标放到这里,然后按
- code [ref=e58]: /
- text:
- generic [ref=e59]:
- generic [ref=e60]:
- text: 当前 P0 边界:
- code [ref=e61]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e62]:
- text: 运行时扩展不再走
- code [ref=e63]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e64]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e65]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e66] [cursor=pointer]:
- generic [ref=e67]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e68]: 展开 / 收起
@@ -0,0 +1,72 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- textbox [ref=e37]:
- heading "准备接管主编辑器体验" [level=1] [ref=e38]
- paragraph [ref=e39]:
- text: 这页现在先只在
- strong [ref=e40]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e41]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e42]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e43]:
- listitem [ref=e44]:
- paragraph [ref=e45]: 标题、段落、引用、代码块
- listitem [ref=e46]:
- paragraph [ref=e47]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e48]:
- paragraph [ref=e49]: 分割线、行内样式、链接和高亮
- paragraph [ref=e50]:
- text: 请把光标放到这里,然后按
- code [ref=e51]: /
- text:
- generic [ref=e52]:
- generic [ref=e53]:
- text: 当前 P0 边界:
- code [ref=e54]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e55]:
- text: 运行时扩展不再走
- code [ref=e56]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e57]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e58]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e59] [cursor=pointer]:
- generic [ref=e60]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e61]: 展开 / 收起
@@ -0,0 +1,5 @@
- generic [active] [ref=e1]:
- generic [ref=e3]:
- img [ref=e4]
- generic [ref=e7]: Connecting...
- alert [ref=e8]
@@ -0,0 +1,238 @@
- generic [active] [ref=e1]:
- alert [ref=e8]
- generic [ref=e9]:
- banner [ref=e10]:
- generic [ref=e11]:
- group [ref=e12]:
- group [ref=e13]:
- button "Undo" [disabled] [ref=e14]:
- img [ref=e15]
- group [ref=e17]:
- button "Redo" [disabled] [ref=e18]:
- img [ref=e19]
- separator [ref=e21]
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
- img [ref=e23]
- separator [ref=e33]
- button [ref=e34] [cursor=pointer]
- generic [ref=e42]:
- generic [ref=e43]:
- paragraph [ref=e44]: Write, type '/' for commands…
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e45]:
- text: Welcome to Notion-like template
- img "sparkles emoji" [ref=e47]
- blockquote [ref=e48]:
- paragraph [ref=e49]:
- img "love_letter emoji" [ref=e51]
- strong [ref=e52]: Invite your colleagues to make this fun!
- text: Just copy the URL from your browser and share it everyone with the link can join in and collaborate in real time.
- generic:
- generic: John Anderson
- paragraph [ref=e53]:
- text: Start writing your thoughts here …
- img "pencil2 emoji" [ref=e55]
- text: Try some
- strong [ref=e56]: "Markdown:"
- paragraph [ref=e57]: /
- paragraph [ref=e58]: /
- code [ref=e60]: "# Headings - Lists > Quotes `Inline code`"
- paragraph [ref=e61]:
- text: Or type
- code [ref=e62]: /
- text: to open the command menu and discover blocks, formatting, and hidden features.
- separator [ref=e64]
- heading "Make it yours" [level=1] [ref=e65]
- list [ref=e66]:
- listitem [ref=e67]:
- paragraph [ref=e68]:
- strong [ref=e69]: Select text
- text: "to reveal a floating toolbar:"
- emphasis [ref=e70]: Quickly italicize,
- emphasis [ref=e72]: color
- emphasis [ref=e73]: ", add"
- link "links" [ref=e74]:
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
- emphasis [ref=e75]: links
- emphasis [ref=e76]:
- text: ", or"
- mark [ref=e77]: highlight text
- text: just as you're used to..
- listitem [ref=e78]:
- paragraph [ref=e79]:
- strong [ref=e80]: Hover near any block
- text: to reveal the context handle
- code [ref=e81]:
- emphasis [ref=e82]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
- listitem [ref=e83]:
- paragraph [ref=e84]:
- text: Mention teammates with
- code [ref=e85]: "@"
- text: and add some fun with emoji
- code [ref=e86]: ":"
- img "partying_face emoji" [ref=e88]
- listitem [ref=e89]:
- paragraph [ref=e90]:
- text: Switch between
- img "sunny emoji" [ref=e92]
- text: light and
- img "new_moon emoji" [ref=e94]
- text: dark mode whatever fits your mood.
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e99] [cursor=pointer]
- blockquote [ref=e100]:
- paragraph [ref=e101]:
- strong [ref=e102]: Need a spark?
- img "sparkles emoji" [ref=e104]
- strong [ref=e105]
- text: Summon the AI Assistant with
- code [ref=e106]: /ask ai
- text: from the context menu, or by selecting text and choosing
- code [ref=e107]: Improve
- text: .
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e108]:
- img "white_check_mark emoji" [ref=e110]
- strong [ref=e111]
- text: Interactive Tables Included
- paragraph [ref=e112]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
- table [ref=e116]:
- rowgroup [ref=e122]:
- row "Name Role Department Location" [ref=e123]:
- columnheader "Name" [ref=e124]:
- paragraph [ref=e125]: Name
- columnheader "Role" [ref=e126]:
- paragraph [ref=e127]: Role
- columnheader "Department" [ref=e128]:
- paragraph [ref=e129]: Department
- columnheader "Location" [ref=e130]:
- paragraph [ref=e131]: Location
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e132]:
- cell "Alice Johnson" [ref=e133]:
- paragraph [ref=e134]: Alice Johnson
- cell "Senior Developer" [ref=e135]:
- paragraph [ref=e136]: Senior Developer
- cell "Engineering" [ref=e137]:
- paragraph [ref=e138]: Engineering
- cell "San Francisco" [ref=e139]:
- paragraph [ref=e140]: San Francisco
- row "Bob Smith Product Manager Product New York" [ref=e141]:
- cell "Bob Smith" [ref=e142]:
- paragraph [ref=e143]: Bob Smith
- cell "Product Manager" [ref=e144]:
- paragraph [ref=e145]: Product Manager
- cell "Product" [ref=e146]:
- paragraph [ref=e147]: Product
- cell "New York" [ref=e148]:
- paragraph [ref=e149]: New York
- row "Carol White UX Designer Design London" [ref=e150]:
- cell "Carol White" [ref=e151]:
- paragraph [ref=e152]: Carol White
- cell "UX Designer" [ref=e153]:
- paragraph [ref=e154]: UX Designer
- cell "Design" [ref=e155]:
- paragraph [ref=e156]: Design
- cell "London" [ref=e157]:
- paragraph [ref=e158]: London
- row "David Chen Data Analyst Analytics Remote" [ref=e159]:
- cell "David Chen" [ref=e160]:
- paragraph [ref=e161]: David Chen
- cell "Data Analyst" [ref=e162]:
- paragraph [ref=e163]: Data Analyst
- cell "Analytics" [ref=e164]:
- paragraph [ref=e165]: Analytics
- cell "Remote" [ref=e166]:
- paragraph [ref=e167]: Remote
- heading "Table of Content" [level=2] [ref=e168]
- generic [ref=e170]:
- generic [ref=e171]: Table of contents
- navigation "Table of contents" [ref=e172]:
- link "Welcome to Notion-like template" [ref=e173] [cursor=pointer]:
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours" [ref=e174] [cursor=pointer]:
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included" [ref=e175] [cursor=pointer]:
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content" [ref=e176] [cursor=pointer]:
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist" [ref=e177] [cursor=pointer]:
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart" [ref=e178] [cursor=pointer]:
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description" [ref=e179] [cursor=pointer]:
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S." [ref=e180] [cursor=pointer]:
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
- heading "Checklist" [level=2] [ref=e181]
- list [ref=e182]:
- listitem [ref=e253]:
- generic [ref=e254]:
- checkbox "Task item checkbox for Read up to this point" [checked]
- paragraph [ref=e257]: Read up to this point
- listitem [ref=e258]:
- generic [ref=e259]:
- checkbox "Task item checkbox for Try a slash command"
- paragraph [ref=e262]: Try a slash command
- listitem [ref=e263]:
- generic [ref=e264]:
- checkbox "Task item checkbox for Mention someone"
- paragraph [ref=e267]: Mention someone
- listitem [ref=e268]:
- generic [ref=e269]:
- checkbox "Task item checkbox for Use the floating toolbar"
- paragraph [ref=e272]: Use the floating toolbar
- listitem [ref=e273]:
- generic [ref=e274]:
- checkbox "Task item checkbox for Add a color highlight"
- paragraph [ref=e277]: Add a color highlight
- listitem [ref=e278]:
- generic [ref=e279]:
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
- paragraph [ref=e282]: Explore the context menu & drag blocks
- listitem [ref=e283]:
- generic [ref=e284]:
- checkbox "Task item checkbox for Ask the AI for help"
- paragraph [ref=e287]: Ask the AI for help
- separator [ref=e219]
- heading "Developer quickstart" [level=1] [ref=e220]
- paragraph [ref=e221]: "Ready to build your own editor? Just run:"
- code [ref=e223]: npx @tiptap/cli init
- blockquote [ref=e224]:
- paragraph [ref=e225]:
- strong [ref=e226]: Did you know?
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools like the AI Assistant, advanced color palettes, or context menus are exclusive to paid users.
- text: Unlock even more possibilities by
- link "upgrading your plan" [ref=e227]:
- /url: https://tiptap.dev/pricing
- text: "!"
- heading "Short description" [level=3] [ref=e228]
- list [ref=e229]:
- listitem [ref=e230]:
- paragraph [ref=e231]: Content blocks = Node Components
- listitem [ref=e232]:
- paragraph [ref=e233]: Toolbars, menus, and buttons = UI Components
- separator [ref=e235]
- heading "P.S." [level=2] [ref=e236]
- paragraph [ref=e237]: Youre using the Notion-like template, available for paid users.
- generic [ref=e289]:
- button "Insert block" [ref=e290] [cursor=pointer]:
- img [ref=e291]
- button [ref=e293]:
- img [ref=e294]
- generic [ref=e239]:
- navigation "Table of contents":
- generic:
- link "Welcome to Notion-like template":
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
- link "Make it yours":
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
- link "Interactive Tables Included":
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
- link "Table of Content":
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
- link "Checklist":
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
- link "Developer quickstart":
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
- link "Short description":
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
- link "P.S.":
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
@@ -0,0 +1,77 @@
- main [ref=e2]:
- generic [ref=e3]:
- generic [ref=e4]:
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
- paragraph [ref=e7]:
- text: 这里继续只做
- code [ref=e8]: "8123"
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
- code [ref=e9]: /documents/[id]
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
- generic [ref=e10]:
- generic [ref=e11]:
- generic [ref=e12]: 变更计数
- strong [ref=e13]: "0"
- generic [ref=e14]:
- generic [ref=e15]: Todo Schema
- strong [ref=e16]: pending
- generic [ref=e17]:
- generic [ref=e18]: Selection
- strong [ref=e19]: 当前块:段落 | 行内样式:无行内样式
- generic [ref=e20]:
- generic [ref=e21]:
- generic [ref=e22]:
- generic [ref=e23]: 空间 / 主文档 /
- strong [ref=e24]: Editor Baseline Reset
- generic [ref=e25]:
- generic [ref=e26]: 反馈:
- strong [ref=e27]: 编辑器已准备就绪,并已恢复本地草稿
- textbox "Spike 页面标题" [ref=e28]:
- /placeholder: 给这篇页面起个标题
- text: Leptos Tiptap 主编辑器 P0
- generic [ref=e29]:
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
- generic [ref=e35]:
- generic [ref=e62]:
- button "Insert block" [ref=e63] [cursor=pointer]:
- generic [ref=e64]: +
- button "Open block menu" [ref=e65]: ⠿
- textbox [ref=e37]:
- heading "准备接管主编辑器体验" [level=1] [ref=e38]
- paragraph [ref=e39]:
- text: 这页现在先只在
- strong [ref=e40]: "8123"
- text: 做 P0 体验收口:你可以输入
- code [ref=e41]: /
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
- paragraph [ref=e42]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
- list [ref=e43]:
- listitem [ref=e44]:
- paragraph [ref=e45]: 标题、段落、引用、代码块
- listitem [ref=e46]:
- paragraph [ref=e47]: 无序列表 / 有序列表 / Todo 列表
- listitem [ref=e48]:
- paragraph [ref=e49]: 分割线、行内样式、链接和高亮
- paragraph [ref=e50]:
- text: 请把光标放到这里,然后按
- code [ref=e51]: /
- text:
- generic [ref=e52]:
- generic [ref=e53]:
- text: 当前 P0 边界:
- code [ref=e54]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
- generic [ref=e55]:
- text: 运行时扩展不再走
- code [ref=e56]: TiptapExtension::all_enabled()
- text: ,这里已冻结为最小集合;当前 reload 走
- code [ref=e57]: localStorage JSON
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
- group [ref=e58]:
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e59] [cursor=pointer]:
- generic [ref=e60]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
- generic [ref=e61]: 展开 / 收起
+95
View File
@@ -124,3 +124,98 @@
[2026-04-18T01:37:10Z] [SESSION-5] Completed [task-041] (commit skipped by repo rule) [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] 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: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)
+1201 -121
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

+10
View File
@@ -1381,12 +1381,20 @@ dependencies = [
"bridge-runtime", "bridge-runtime",
"clap", "clap",
"core-protocol", "core-protocol",
"mnote-editor-core",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"storage-convex-bridge", "storage-convex-bridge",
] ]
[[package]]
name = "mnote-editor-core"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "mnote-web" name = "mnote-web"
version = "0.1.0" version = "0.1.0"
@@ -1397,9 +1405,11 @@ dependencies = [
"core-protocol", "core-protocol",
"futures-util", "futures-util",
"leptos", "leptos",
"mnote-editor-core",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"storage-convex-bridge",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
+1
View File
@@ -5,6 +5,7 @@ members = [
"crates/core-domain", "crates/core-domain",
"crates/core-protocol", "crates/core-protocol",
"crates/event-log", "crates/event-log",
"crates/mnote-editor-core",
"crates/mnote-cli", "crates/mnote-cli",
"crates/mnote-web", "crates/mnote-web",
"crates/storage-convex-bridge", "crates/storage-convex-bridge",
+35 -10
View File
@@ -244,7 +244,10 @@ pub fn prepare_proxy_request(
} }
if let Some(origin) = supa_internal_origin.as_ref() { if let Some(origin) = supa_internal_origin.as_ref() {
let port = origin.port().map(|value| value.to_string()).unwrap_or_default(); let port = origin
.port()
.map(|value| value.to_string())
.unwrap_or_default();
if let Some(hostname) = origin.host_str() { if let Some(hostname) = origin.host_str() {
add_allowed(hostname, &port); add_allowed(hostname, &port);
if is_local_hostname(hostname) { if is_local_hostname(hostname) {
@@ -260,7 +263,10 @@ pub fn prepare_proxy_request(
} }
if let Some(origin) = convex_origin.as_ref() { if let Some(origin) = convex_origin.as_ref() {
let port = origin.port().map(|value| value.to_string()).unwrap_or_default(); let port = origin
.port()
.map(|value| value.to_string())
.unwrap_or_default();
if let Some(hostname) = origin.host_str() { if let Some(hostname) = origin.host_str() {
add_allowed(hostname, &port); add_allowed(hostname, &port);
if is_local_hostname(hostname) { if is_local_hostname(hostname) {
@@ -275,7 +281,10 @@ pub fn prepare_proxy_request(
.host_str() .host_str()
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?; .ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
if is_private_ipv4(hostname) && !is_local_hostname(hostname) && !allowed_hostnames.contains(hostname) { if is_private_ipv4(hostname)
&& !is_local_hostname(hostname)
&& !allowed_hostnames.contains(hostname)
{
return Err("禁止访问内网/私有地址".into()); return Err("禁止访问内网/私有地址".into());
} }
@@ -303,8 +312,14 @@ pub fn prepare_proxy_request(
}); });
} }
if let Some(anon_key) = input.supabase_anon_key.filter(|value| !value.trim().is_empty()) { if let Some(anon_key) = input
let matches_public = supa.as_ref().map(|(value, _)| value == hostname).unwrap_or(false); .supabase_anon_key
.filter(|value| !value.trim().is_empty())
{
let matches_public = supa
.as_ref()
.map(|(value, _)| value == hostname)
.unwrap_or(false);
let matches_internal = supa_internal_origin let matches_internal = supa_internal_origin
.as_ref() .as_ref()
.and_then(|value| value.host_str()) .and_then(|value| value.host_str())
@@ -332,7 +347,10 @@ pub fn prepare_callback(
workspace_id: input.workspace_id.clone(), workspace_id: input.workspace_id.clone(),
document_id: input.document_id.clone(), document_id: input.document_id.clone(),
user_id: input.user_id.clone(), user_id: input.user_id.clone(),
session_id: input.session_id.clone().or_else(|| Some("onlyoffice-callback".into())), session_id: input
.session_id
.clone()
.or_else(|| Some("onlyoffice-callback".into())),
}); });
if input.status != 2 && input.status != 6 { if input.status != 2 && input.status != 6 {
@@ -464,7 +482,8 @@ fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes()); let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes()); let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
let signing_input = format!("{header_part}.{payload_part}"); let signing_input = format!("{header_part}.{payload_part}");
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?; let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
mac.update(signing_input.as_bytes()); mac.update(signing_input.as_bytes());
let signature = mac.finalize().into_bytes(); let signature = mac.finalize().into_bytes();
let signature_part = URL_SAFE_NO_PAD.encode(signature); let signature_part = URL_SAFE_NO_PAD.encode(signature);
@@ -512,7 +531,9 @@ fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
let url = Url::parse(value).ok()?; let url = Url::parse(value).ok()?;
return Some(( return Some((
url.host_str()?.to_string(), url.host_str()?.to_string(),
url.port().map(|value| value.to_string()).unwrap_or_default(), url.port()
.map(|value| value.to_string())
.unwrap_or_default(),
)); ));
} }
Some((value.to_string(), String::new())) Some((value.to_string(), String::new()))
@@ -574,7 +595,9 @@ mod tests {
supabase_anon_key: Some("anon".into()), supabase_anon_key: Some("anon".into()),
}) })
.expect("proxy should prepare"); .expect("proxy should prepare");
assert!(result.target_url.starts_with("http://127.0.0.1:18000/storage/v1/")); assert!(result
.target_url
.starts_with("http://127.0.0.1:18000/storage/v1/"));
assert_eq!(result.forward_headers.len(), 2); assert_eq!(result.forward_headers.len(), 2);
} }
@@ -587,7 +610,9 @@ mod tests {
user_id: Some("user_1".into()), user_id: Some("user_1".into()),
session_id: None, session_id: None,
status: 6, status: 6,
url: Some("http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into()), url: Some(
"http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into(),
),
key: Some("doc_key_1".into()), key: Some("doc_key_1".into()),
onlyoffice_internal_url: "http://127.0.0.1:8082".into(), onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
}) })
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,143 @@
use crate::editor::model::{BlockProps, ContentNode, EditorBlock, EditorBlockType, ReferenceToken};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EditorCommandKind {
ReplaceBlock,
InsertBlockAfter,
DeleteBlock,
SplitBlock,
MergeWithPrevious,
MoveBlock,
IndentBlock,
OutdentBlock,
ToggleHeadingCollapse,
AttachReferenceToken,
DetachReferenceToken,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorReplaceBlock {
pub block_id: String,
pub block_type: Option<EditorBlockType>,
pub props: Option<BlockProps>,
pub content_nodes: Option<Vec<ContentNode>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorInsertBlockAfter {
pub after_block_id: String,
pub block: EditorBlock,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorDeleteBlock {
pub block_id: String,
pub preserve_children: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorSplitBlock {
pub block_id: String,
pub content_node_index: u32,
pub text_offset: Option<u32>,
pub trailing_block_type: Option<EditorBlockType>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorMergeWithPrevious {
pub block_id: String,
pub previous_block_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorMoveBlock {
pub block_id: String,
pub parent_block_id: Option<String>,
pub after_block_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorIndentBlock {
pub block_id: String,
pub max_depth: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorOutdentBlock {
pub block_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorToggleHeadingCollapse {
pub block_id: String,
pub collapsed: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAttachReferenceToken {
pub block_id: String,
pub content_node_index: u32,
pub token: ReferenceToken,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorDetachReferenceToken {
pub block_id: String,
pub content_node_index: u32,
pub target_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EditorCommand {
ReplaceBlock(EditorReplaceBlock),
InsertBlockAfter(EditorInsertBlockAfter),
DeleteBlock(EditorDeleteBlock),
SplitBlock(EditorSplitBlock),
MergeWithPrevious(EditorMergeWithPrevious),
MoveBlock(EditorMoveBlock),
IndentBlock(EditorIndentBlock),
OutdentBlock(EditorOutdentBlock),
ToggleHeadingCollapse(EditorToggleHeadingCollapse),
AttachReferenceToken(EditorAttachReferenceToken),
DetachReferenceToken(EditorDetachReferenceToken),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorKernelCommandMapping {
pub editor_command: EditorCommandKind,
pub kernel_command_name: String,
pub lossy: bool,
pub notes: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCommandDescriptor {
pub kind: EditorCommandKind,
pub label: String,
pub write_command: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct EditorCommandCatalog {
#[serde(default)]
pub commands: Vec<EditorCommandDescriptor>,
#[serde(default)]
pub kernel_command_mappings: Vec<EditorKernelCommandMapping>,
}
@@ -0,0 +1,91 @@
use crate::editor::model::{EditorBlockDocument, ReferenceTokenStrategy};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MarkdownFlavor {
Commonmark,
Gfm,
Mnote,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MarkdownImportMode {
ReplaceDocument,
ReplaceSelection,
MergeIntoDocument,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MarkdownExportMode {
FullDocument,
Selection,
SingleBlock,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownImportBoundary {
pub allow_front_matter: bool,
pub allow_html_blocks: bool,
pub allow_reference_tokens: bool,
pub unsupported_blocks_as_paragraph: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownExportBoundary {
pub preserve_heading_collapse: bool,
pub preserve_reference_tokens: bool,
pub allow_lossy_block_fallback: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownImportOptions {
pub mode: MarkdownImportMode,
pub flavor: MarkdownFlavor,
pub reference_token_strategy: ReferenceTokenStrategy,
pub boundary: MarkdownImportBoundary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownExportOptions {
pub mode: MarkdownExportMode,
pub flavor: MarkdownFlavor,
pub reference_token_strategy: ReferenceTokenStrategy,
pub boundary: MarkdownExportBoundary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownImportRequest {
pub markdown: String,
pub options: MarkdownImportOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownImportResult {
pub document: EditorBlockDocument,
#[serde(default)]
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownExportRequest {
pub document: EditorBlockDocument,
pub options: MarkdownExportOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MarkdownExportResult {
pub markdown: String,
#[serde(default)]
pub warnings: Vec<String>,
}
@@ -0,0 +1,56 @@
pub mod command;
pub mod markdown;
pub mod model;
pub use command::{
EditorAttachReferenceToken, EditorCommand, EditorCommandCatalog, EditorCommandDescriptor,
EditorCommandKind, EditorDeleteBlock, EditorDetachReferenceToken, EditorIndentBlock,
EditorInsertBlockAfter, EditorKernelCommandMapping, EditorMergeWithPrevious, EditorMoveBlock,
EditorOutdentBlock, EditorReplaceBlock, EditorSplitBlock, EditorToggleHeadingCollapse,
};
pub use markdown::{
MarkdownExportBoundary, MarkdownExportMode, MarkdownExportOptions, MarkdownExportRequest,
MarkdownExportResult, MarkdownFlavor, MarkdownImportBoundary, MarkdownImportMode,
MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
};
pub use model::{
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_command_kind_uses_phase_one_names() {
assert_eq!(
serde_json::to_string(&EditorCommandKind::InsertBlockAfter).unwrap(),
"\"insert_block_after\""
);
assert_eq!(
serde_json::to_string(&EditorCommandKind::MergeWithPrevious).unwrap(),
"\"merge_with_previous\""
);
assert_eq!(
serde_json::to_string(&EditorCommandKind::ToggleHeadingCollapse).unwrap(),
"\"toggle_heading_collapse\""
);
}
#[test]
fn editor_block_type_uses_expected_snake_case_names() {
assert_eq!(
serde_json::to_string(&EditorBlockType::BulletListItem).unwrap(),
"\"bullet_list_item\""
);
assert_eq!(
serde_json::to_string(&EditorBlockType::PageReference).unwrap(),
"\"page_reference\""
);
assert_eq!(
serde_json::to_string(&EditorBlockType::BlockReference).unwrap(),
"\"block_reference\""
);
}
}
@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EditorBlockType {
Paragraph,
Heading,
BulletListItem,
NumberedListItem,
Quote,
Todo,
CodeBlock,
PageReference,
BlockReference,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReferenceTokenKind {
PageReference,
BlockReference,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReferenceTokenStrategy {
DoubleBracket,
DoubleParen,
InlineChip,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TextMark {
Bold,
Italic,
Underline,
Strike,
Code,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ReferenceToken {
pub kind: ReferenceTokenKind,
pub strategy: ReferenceTokenStrategy,
pub target_id: String,
pub label: Option<String>,
pub raw_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentNodePayload {
Text {
text: String,
#[serde(default)]
marks: Vec<TextMark>,
},
HardBreak,
ReferenceToken {
token: ReferenceToken,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContentNode {
pub payload: ContentNodePayload,
#[serde(default)]
pub attrs: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct BlockProps {
pub indent: Option<u16>,
pub heading_level: Option<u8>,
pub checked: Option<bool>,
pub collapsed: Option<bool>,
pub language: Option<String>,
pub reference_target_id: Option<String>,
pub reference_label: Option<String>,
pub reference_token_strategy: Option<ReferenceTokenStrategy>,
#[serde(default)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorBlock {
pub block_id: String,
pub block_type: EditorBlockType,
#[serde(default)]
pub props: BlockProps,
#[serde(default)]
pub content_nodes: Vec<ContentNode>,
#[serde(default)]
pub child_block_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct EditorBlockDocument {
pub document_id: String,
#[serde(default)]
pub root_block_ids: Vec<String>,
#[serde(default)]
pub blocks: Vec<EditorBlock>,
}
+119 -1
View File
@@ -352,6 +352,121 @@ pub struct KernelProjectionResult {
pub edges: Vec<KernelEdge>, pub edges: Vec<KernelEdge>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DocumentReadNodeType {
Page,
Section,
ContentNode,
ReferenceAnchor,
Mindmap,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadNodeMeta {
pub title: Option<String>,
pub text_snippet: Option<String>,
pub block_type: Option<String>,
pub heading_level: Option<u32>,
pub numbering: Option<String>,
pub child_count: u32,
pub order: u32,
#[serde(default)]
pub path: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadNode {
pub id: String,
pub parent_node_id: Option<String>,
pub node_type: DocumentReadNodeType,
pub block_id: Option<String>,
pub anchor_block_id: Option<String>,
pub depth: u32,
pub metadata: DocumentReadNodeMeta,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadSubtree {
pub root_node_id: String,
pub nodes: Vec<DocumentReadNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadOutlineEntry {
pub id: String,
pub node_id: String,
pub anchor_block_id: Option<String>,
pub title: String,
pub level: u32,
pub numbering: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DocumentReadEvidenceKind {
Page,
Heading,
Paragraph,
List,
Todo,
Quote,
Code,
Media,
Reference,
Table,
Mindmap,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadEvidenceItem {
pub id: String,
pub node_id: String,
pub block_id: Option<String>,
pub kind: DocumentReadEvidenceKind,
pub snippet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadStats {
pub block_count: u32,
pub heading_count: u32,
pub evidence_count: u32,
pub max_depth: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentReadPageSubtree {
pub projection_id: String,
pub projection: String,
pub root_node_id: String,
pub root_node: DocumentReadNode,
pub subtree: DocumentReadSubtree,
#[serde(default)]
pub outline: Vec<DocumentReadOutlineEntry>,
#[serde(default)]
pub evidence: Vec<DocumentReadEvidenceItem>,
pub stats: DocumentReadStats,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentContentResult {
pub content: Value,
pub revision: u64,
pub conflict_detection_key: String,
pub title: Option<String>,
pub page_subtree: DocumentReadPageSubtree,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct KernelCreateNode { pub struct KernelCreateNode {
@@ -508,7 +623,10 @@ mod tests {
assert_eq!(value["rowId"], json!("asset:page_1")); assert_eq!(value["rowId"], json!("asset:page_1"));
assert_eq!(value["rowKind"], json!("asset")); assert_eq!(value["rowKind"], json!("asset"));
assert_eq!(value["projectionKind"], json!("file_tree")); assert_eq!(value["projectionKind"], json!("file_tree"));
assert_eq!(value["capabilities"], json!(["expand", "open", "create-child"])); assert_eq!(
value["capabilities"],
json!(["expand", "open", "create-child"])
);
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document")); assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
assert_eq!(value["iconHint"], json!("page")); assert_eq!(value["iconHint"], json!("page"));
} }
+44 -23
View File
@@ -1,5 +1,6 @@
pub mod command; pub mod command;
pub mod common; pub mod common;
pub mod editor;
pub mod governance; pub mod governance;
pub mod kernel; pub mod kernel;
pub mod mindmap; pub mod mindmap;
@@ -17,39 +18,49 @@ pub use common::{
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta, ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
SourcePayload, TargetRef, SourcePayload, TargetRef,
}; };
pub use editor::{
BlockProps, ContentNode, ContentNodePayload, EditorAttachReferenceToken, EditorBlock,
EditorBlockDocument, EditorBlockType, EditorCommand, EditorCommandCatalog,
EditorCommandDescriptor, EditorCommandKind, EditorDeleteBlock, EditorDetachReferenceToken,
EditorIndentBlock, EditorInsertBlockAfter, EditorKernelCommandMapping, EditorMergeWithPrevious,
EditorMoveBlock, EditorOutdentBlock, EditorReplaceBlock, EditorSplitBlock,
EditorToggleHeadingCollapse, MarkdownExportBoundary, MarkdownExportMode, MarkdownExportOptions,
MarkdownExportRequest, MarkdownExportResult, MarkdownFlavor, MarkdownImportBoundary,
MarkdownImportMode, MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
};
pub use kernel::{ pub use kernel::{
KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode, DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode, DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit, DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult,
KernelNodeType, KernelProjectionAssetKind, KernelProjectionCapability, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
}; };
pub use mindmap::{ pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode};
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode,
};
pub use query::{ pub use query::{
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage, GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset,
ListSidebarDataset, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
}; };
pub use tool::{ pub use tool::{
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label, default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec, InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET, ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOCS_TOOLSET_READ, DOC_TOOL_FIND, DOC_TOOL_GET, DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE, DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
INDEX_TOOL_REBUILD, MINDMAP_TOOL_APPLY_OPS, INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
MINDMAP_TOOL_GET_SUBTREE, MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ, ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
MINDMAP_TOOLSET_WRITE, OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY, ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, RECOVERY_TOOLSET_JOB,
RECOVERY_TOOLSET_JOB, REPLAY_TOOL_EVENTS, REPLAY_TOOL_EVENTS,
}; };
#[cfg(test)] #[cfg(test)]
@@ -164,9 +175,15 @@ mod tests {
.toolset("toolset.doc_write") .toolset("toolset.doc_write")
.expect("doc_write toolset should exist"); .expect("doc_write toolset should exist");
assert!(doc_write.write_toolset); assert!(doc_write.write_toolset);
assert_eq!(doc_write.tool_names, &["doc_insert_blocks", "doc_replace_range"]);
assert_eq!( assert_eq!(
registry.tool("doc_get").expect("doc_get should exist").toolset_id, doc_write.tool_names,
&["doc_insert_blocks", "doc_replace_range"]
);
assert_eq!(
registry
.tool("doc_get")
.expect("doc_get should exist")
.toolset_id,
"toolset.doc_read" "toolset.doc_read"
); );
let mindmap_write = registry let mindmap_write = registry
@@ -203,7 +220,11 @@ mod tests {
assert!(!observe.write_toolset); assert!(!observe.write_toolset);
assert_eq!( assert_eq!(
observe.tool_names, observe.tool_names,
&["bridge_request_get", "bridge_trace_get", "bridge_command_get"] &[
"bridge_request_get",
"bridge_trace_get",
"bridge_command_get"
]
); );
let recovery = registry let recovery = registry
.toolset("toolset.recovery_job") .toolset("toolset.recovery_job")
+31 -55
View File
@@ -71,8 +71,7 @@ impl ToolRegistry {
} }
pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> { pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> {
self.toolset(id) self.toolset(id).map(|toolset| toolset.tool_names.to_vec())
.map(|toolset| toolset.tool_names.to_vec())
} }
pub fn tools_in_set(&self, id: &str) -> Option<Vec<&'static ToolSpec>> { pub fn tools_in_set(&self, id: &str) -> Option<Vec<&'static ToolSpec>> {
@@ -105,8 +104,7 @@ pub const SEARCH_WEB_TOOL: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
}; };
pub const DOC_TOOL_FIND: ToolSpec = ToolSpec { pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
@@ -117,11 +115,9 @@ pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
}; };
pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec { pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
name: "docs_search", name: "docs_search",
display_name: "跨页文档搜索", display_name: "跨页文档搜索",
@@ -130,8 +126,7 @@ pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
}; };
pub const DOCS_TOOL_READ: ToolSpec = ToolSpec { pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
@@ -142,8 +137,7 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
}; };
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec { pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
@@ -154,8 +148,7 @@ pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
}; };
pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec { pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
@@ -166,8 +159,7 @@ pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
}; };
pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec { pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
@@ -178,8 +170,7 @@ pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
}; };
pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec { pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
@@ -190,8 +181,7 @@ pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
}; };
pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec { pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
@@ -202,8 +192,7 @@ pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
}; };
pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec { pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
@@ -214,8 +203,7 @@ pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
}; };
pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec { pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
@@ -226,8 +214,7 @@ pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
}; };
pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec { pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
@@ -238,8 +225,7 @@ pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
}; };
pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec { pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
@@ -250,8 +236,7 @@ pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
}; };
pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec { pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
@@ -262,8 +247,7 @@ pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
}; };
pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec { pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
@@ -274,8 +258,7 @@ pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Command, invocation_kind: InvocationKind::Command,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
}; };
pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec { pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
@@ -286,8 +269,7 @@ pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
}; };
pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec { pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
@@ -298,8 +280,7 @@ pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
}; };
pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec { pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
@@ -310,8 +291,7 @@ pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Query, invocation_kind: InvocationKind::Query,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
}; };
pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec { pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
@@ -322,8 +302,7 @@ pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Job, invocation_kind: InvocationKind::Job,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
}; };
pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec { pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
@@ -334,8 +313,7 @@ pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Job, invocation_kind: InvocationKind::Job,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: true, requires_confirmation: true,
input_schema_json: input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
}; };
pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec { pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
@@ -346,8 +324,7 @@ pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Job, invocation_kind: InvocationKind::Job,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
}; };
pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec { pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
@@ -358,8 +335,7 @@ pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Job, invocation_kind: InvocationKind::Job,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
}; };
pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec { pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
@@ -370,8 +346,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Job, invocation_kind: InvocationKind::Job,
effect: ToolEffect::Read, effect: ToolEffect::Read,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
}; };
pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec { pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
@@ -382,8 +357,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Job, invocation_kind: InvocationKind::Job,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
}; };
pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec { pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
@@ -394,8 +368,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
invocation_kind: InvocationKind::Job, invocation_kind: InvocationKind::Job,
effect: ToolEffect::Write, effect: ToolEffect::Write,
requires_confirmation: false, requires_confirmation: false,
input_schema_json: input_schema_json: r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
}; };
pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec { pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
@@ -406,7 +379,6 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
tool_names: &["doc_get", "doc_find"], tool_names: &["doc_get", "doc_find"],
}; };
pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec { pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
id: "toolset.docs_read", id: "toolset.docs_read",
display_name: "跨页文档读取", display_name: "跨页文档读取",
@@ -466,7 +438,11 @@ pub const OBSERVE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
display_name: "统一观测", display_name: "统一观测",
description: "按 request、trace、command 回查统一日志与事件视图。", description: "按 request、trace、command 回查统一日志与事件视图。",
write_toolset: false, write_toolset: false,
tool_names: &["bridge_request_get", "bridge_trace_get", "bridge_command_get"], tool_names: &[
"bridge_request_get",
"bridge_trace_get",
"bridge_command_get",
],
}; };
pub const RECOVERY_TOOLSET_JOB: ToolSetSpec = ToolSetSpec { pub const RECOVERY_TOOLSET_JOB: ToolSetSpec = ToolSetSpec {
+31 -7
View File
@@ -273,7 +273,9 @@ pub fn search_pages(
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
) )
}) })
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace)) .filter(|document| {
workspace_id.map_or(true, |workspace| document.workspace_id == workspace)
})
.filter(|document| { .filter(|document| {
document document
.title .title
@@ -376,7 +378,8 @@ pub fn evaluate_search_documents(
request.custom_range_to.as_deref(), request.custom_range_to.as_deref(),
), ),
_ => within_range( _ => within_range(
document.updated_at document
.updated_at
.as_deref() .as_deref()
.or(document.created_at.as_deref()), .or(document.created_at.as_deref()),
boundary_iso.as_deref(), boundary_iso.as_deref(),
@@ -386,7 +389,10 @@ pub fn evaluate_search_documents(
}) })
.collect(); .collect();
let eligible_doc_ids: HashSet<&str> = eligible_docs.iter().map(|document| document.id.as_str()).collect(); let eligible_doc_ids: HashSet<&str> = eligible_docs
.iter()
.map(|document| document.id.as_str())
.collect();
let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
.iter() .iter()
.map(|document| (document.id.as_str(), *document)) .map(|document| (document.id.as_str(), *document))
@@ -511,7 +517,10 @@ pub fn evaluate_search_documents(
} }
let file_name = asset.file_name.as_deref().unwrap_or("").trim(); let file_name = asset.file_name.as_deref().unwrap_or("").trim();
if !request.title_only && !file_name.is_empty() && file_name.to_lowercase().contains(&normalized_lower) { if !request.title_only
&& !file_name.is_empty()
&& file_name.to_lowercase().contains(&normalized_lower)
{
upsert_match( upsert_match(
&mut matches, &mut matches,
&asset.document_id, &asset.document_id,
@@ -552,7 +561,14 @@ pub fn evaluate_search_documents(
score: 1.7, score: 1.7,
match_field: SearchMatchField::Content, match_field: SearchMatchField::Content,
snippet: build_snippet( snippet: build_snippet(
&format!("附件:{}\n{ocr_text}", if file_name.is_empty() { asset.id.as_str() } else { file_name }), &format!(
"附件:{}\n{ocr_text}",
if file_name.is_empty() {
asset.id.as_str()
} else {
file_name
}
),
normalized_query, normalized_query,
), ),
has_ocr: true, has_ocr: true,
@@ -798,7 +814,12 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
} }
fn normalize_text(value: &str) -> String { fn normalize_text(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ").trim().to_string() value
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string()
} }
fn escape_html(value: &str) -> String { fn escape_html(value: &str) -> String {
@@ -1090,7 +1111,10 @@ mod tests {
assert_eq!(result.results.len(), 1); assert_eq!(result.results.len(), 1);
assert_eq!(result.results[0].id, "page_1"); assert_eq!(result.results[0].id, "page_1");
assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>")); assert!(
result.results[0].snippet.contains("<mark>Rust</mark>")
|| result.results[0].snippet.contains("<mark>rust</mark>")
);
assert_eq!(result.results[0].node_id.as_deref(), Some("page_1")); assert_eq!(result.results[0].node_id.as_deref(), Some("page_1"));
assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1")); assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1"));
assert_eq!(result.results[0].evidence.len(), 1); assert_eq!(result.results[0].evidence.len(), 1);
+1
View File
@@ -10,6 +10,7 @@ base64 = "0.22"
bridge-runtime = { path = "../bridge-runtime" } bridge-runtime = { path = "../bridge-runtime" }
clap = { version = "4.5.38", features = ["derive"] } clap = { version = "4.5.38", features = ["derive"] }
core-protocol = { path = "../core-protocol" } core-protocol = { path = "../core-protocol" }
mnote-editor-core = { path = "../mnote-editor-core" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+373
View File
@@ -3,6 +3,7 @@ use bridge_runtime::{
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire, RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
}; };
use clap::ValueEnum;
use core_protocol::{ use core_protocol::{
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope, default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind, CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
@@ -10,6 +11,11 @@ use core_protocol::{
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments, QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
}; };
use mnote_editor_core::{
apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock,
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
VisibilitySnapshot,
};
use reqwest::blocking::Client; use reqwest::blocking::Client;
use serde::Serialize; use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -130,6 +136,80 @@ pub struct CliTransportPlan {
pub args_json: Value, pub args_json: Value,
} }
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorCliOutput {
pub ok: bool,
pub entrypoint: &'static str,
pub operation: &'static str,
pub block_count: usize,
pub output_markdown: String,
pub visible_block_ids: Vec<String>,
pub outline: Vec<EditorOutlineItem>,
pub audit: Vec<EditorAuditItem>,
pub change_report: EditorChangeReportOutput,
#[serde(skip_serializing_if = "Option::is_none")]
pub undo_applied: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redo_applied: Option<bool>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorOutlineItem {
pub block_id: String,
pub level: u8,
pub title: String,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAuditItem {
pub command: String,
pub target_block_id: Option<String>,
pub before: Option<EditorBlockSnapshot>,
pub after: Option<EditorBlockSnapshot>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorBlockSnapshot {
pub block_id: String,
pub block_type: String,
pub text: String,
pub parent_id: Option<String>,
pub indent: u16,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct EditorChangeReportOutput {
#[serde(default)]
pub created_blocks: Vec<String>,
#[serde(default)]
pub updated_blocks: Vec<String>,
#[serde(default)]
pub moved_blocks: Vec<String>,
#[serde(default)]
pub removed_blocks: Vec<String>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum EditorInputKindArg {
PlainText,
Markdown,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum EditorAiScenarioArg {
MeetingNotesToTodos,
LongParagraphToTitle,
PageReorder,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PageCreateArgs<'a> { pub struct PageCreateArgs<'a> {
pub page_id: &'a str, pub page_id: &'a str,
@@ -194,6 +274,8 @@ pub fn supported_command_surface() -> Vec<&'static str> {
"search documents", "search documents",
"search blocks", "search blocks",
"sidebar dataset", "sidebar dataset",
"editor markdown-roundtrip",
"editor session-demo",
"tool run", "tool run",
]; ];
for tool_name in default_tool_registry().tool_names() { for tool_name in default_tool_registry().tool_names() {
@@ -223,6 +305,8 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
"search.documents", "search.documents",
"search.blocks", "search.blocks",
"sidebar.dataset", "sidebar.dataset",
"editor.markdown_roundtrip",
"editor.session_demo",
"tool.run", "tool.run",
]; ];
for tool_name in default_tool_registry().tool_names() { for tool_name in default_tool_registry().tool_names() {
@@ -231,6 +315,179 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
contracts contracts
} }
pub fn editor_markdown_roundtrip(markdown: &str) -> CliResult<EditorCliOutput> {
let document = import_markdown(markdown).map_err(map_editor_error)?;
let projection = VisibilitySnapshot::derive(&document);
Ok(build_editor_output(
"markdown_roundtrip",
document.blocks().len(),
export_markdown(&document),
&projection,
Vec::new(),
EditorChangeReportOutput::default(),
None,
None,
))
}
pub fn editor_ai_pipeline(
kind: EditorInputKind,
scenario: EditorAiScenario,
input: &str,
) -> CliResult<EditorCliOutput> {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind,
scenario,
input: input.to_string(),
})
.map_err(map_editor_error)?;
let projection = VisibilitySnapshot::derive(&result.document);
Ok(build_editor_output(
"ai_pipeline",
result.document.blocks().len(),
result.markdown,
&projection,
result
.audit
.into_iter()
.map(|item| EditorAuditItem {
command: item.command,
target_block_id: item.target_block_id,
before: item.before.map(|snapshot| EditorBlockSnapshot {
block_id: snapshot.block_id,
block_type: snapshot.block_type,
text: snapshot.text,
parent_id: snapshot.parent_id,
indent: snapshot.indent,
}),
after: item.after.map(|snapshot| EditorBlockSnapshot {
block_id: snapshot.block_id,
block_type: snapshot.block_type,
text: snapshot.text,
parent_id: snapshot.parent_id,
indent: snapshot.indent,
}),
})
.collect(),
EditorChangeReportOutput {
created_blocks: result.change_report.created_blocks,
updated_blocks: result.change_report.updated_blocks,
moved_blocks: result.change_report.moved_blocks,
removed_blocks: result.change_report.removed_blocks,
notes: result.change_report.notes,
},
None,
None,
))
}
pub fn editor_session_demo(markdown: &str) -> CliResult<EditorCliOutput> {
let document = import_markdown(markdown).map_err(map_editor_error)?;
let bootstrap = if document.blocks().is_empty() {
mnote_editor_core::DocumentModel::new(vec![DocumentBlock::new(
"demo_root",
BlockType::Paragraph,
)
.with_text("demo root")])
} else {
document
};
let mut session = EditorSession::new(bootstrap);
let primary_block_id = session
.document()
.blocks()
.first()
.map(|block| block.id.clone())
.ok_or_else(|| CliError::validation("session demo 缺少可编辑块"))?;
let original_text = session
.document()
.block(&primary_block_id)
.map(|block| block.content.text.clone())
.unwrap_or_default();
let next_text = if original_text.trim().is_empty() {
"demo edited".to_string()
} else {
format!("{original_text} [edited]")
};
session
.apply_command(EditorCommand::ReplaceBlock {
block_id: primary_block_id.clone(),
text: next_text,
})
.map_err(map_editor_error)?;
session
.apply_command(EditorCommand::InsertBlockAfter {
after_block_id: Some(primary_block_id),
block: DocumentBlock::new("cli_demo_block", BlockType::Paragraph)
.with_text("CLI demo block"),
})
.map_err(map_editor_error)?;
let undo_applied = session.undo();
let redo_applied = session.redo();
let projection = VisibilitySnapshot::derive(session.document());
Ok(build_editor_output(
"session_demo",
session.document().blocks().len(),
export_markdown(session.document()),
&projection,
Vec::new(),
EditorChangeReportOutput::default(),
Some(undo_applied),
Some(redo_applied),
))
}
pub fn render_editor_plain_output(output: &EditorCliOutput) -> String {
format!(
"entrypoint={} operation={} block_count={} visible_blocks={} audit_count={} updated_blocks={} undo_applied={} redo_applied={}",
output.entrypoint,
output.operation,
output.block_count,
output.visible_block_ids.join(","),
output.audit.len(),
output.change_report.updated_blocks.join(","),
output.undo_applied.unwrap_or(false),
output.redo_applied.unwrap_or(false),
)
}
fn build_editor_output(
operation: &'static str,
block_count: usize,
output_markdown: String,
projection: &VisibilitySnapshot,
audit: Vec<EditorAuditItem>,
change_report: EditorChangeReportOutput,
undo_applied: Option<bool>,
redo_applied: Option<bool>,
) -> EditorCliOutput {
EditorCliOutput {
ok: true,
entrypoint: "mnote-cli",
operation,
block_count,
output_markdown,
visible_block_ids: projection.visible_block_ids.clone(),
outline: projection
.outline
.iter()
.map(|entry| EditorOutlineItem {
block_id: entry.block_id.clone(),
level: entry.level,
title: entry.title.clone(),
depth: entry.depth,
})
.collect(),
audit,
change_report,
undo_applied,
redo_applied,
}
}
pub fn render_plain_output(output: &CliJsonOutput) -> String { pub fn render_plain_output(output: &CliJsonOutput) -> String {
match &output.operation { match &output.operation {
CliOperationOutput::Command { CliOperationOutput::Command {
@@ -2359,6 +2616,10 @@ fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
CliError::validation(error.message) CliError::validation(error.message)
} }
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
CliError::validation(error.to_string())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -2374,9 +2635,121 @@ mod tests {
assert!(commands.contains(&"mindmap get")); assert!(commands.contains(&"mindmap get"));
assert!(commands.contains(&"search documents")); assert!(commands.contains(&"search documents"));
assert!(commands.contains(&"sidebar dataset")); assert!(commands.contains(&"sidebar dataset"));
assert!(commands.contains(&"editor markdown-roundtrip"));
assert!(commands.contains(&"editor session-demo"));
assert!(commands.contains(&"tool run")); assert!(commands.contains(&"tool run"));
} }
#[test]
fn editor_markdown_roundtrip_returns_outline_and_markdown() {
let output =
editor_markdown_roundtrip("# 标题\n普通段落").expect("roundtrip should succeed");
assert_eq!(output.operation, "markdown_roundtrip");
assert_eq!(output.block_count, 2);
assert!(output.output_markdown.contains("# 标题"));
assert_eq!(
output.visible_block_ids,
vec!["imported_1".to_string(), "imported_2".to_string()]
);
assert_eq!(output.outline.len(), 1);
assert_eq!(output.outline[0].title, "标题");
}
#[test]
fn editor_session_demo_applies_insert_and_redo() {
let output = editor_session_demo("原始段落").expect("session demo should succeed");
assert_eq!(output.operation, "session_demo");
assert_eq!(output.block_count, 2);
assert_eq!(output.undo_applied, Some(true));
assert_eq!(output.redo_applied, Some(true));
assert!(output.output_markdown.contains("原始段落 [edited]"));
assert!(output.output_markdown.contains("CLI demo block"));
}
#[test]
fn editor_ai_pipeline_exposes_audit_and_change_report() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::MeetingNotesToTodos,
"待办:整理会议纪要\n普通段落",
)
.expect("ai pipeline should succeed");
assert_eq!(output.operation, "ai_pipeline");
assert_eq!(output.audit.len(), 1);
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
assert!(output.output_markdown.contains("待办:整理会议纪要"));
}
#[test]
fn editor_ai_pipeline_reorders_pages() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::PageReorder,
"第一页\n\n第二页",
)
.expect("ai pipeline should succeed");
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
assert_eq!(
output.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_meeting_notes_to_todos() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::MeetingNotesToTodos,
"待办:整理会议纪要\n普通段落",
)
.expect("ai regression meeting notes should succeed");
assert!(output.output_markdown.contains("- [ ] 待办:整理会议纪要"));
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_long_paragraph_to_title() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::LongParagraphToTitle,
"这是一个很长的段落,用来验证 AI 回归样例会把首块提炼成标题,并保留后续结构化内容。",
)
.expect("ai regression long paragraph should succeed");
assert!(output.output_markdown.starts_with("# 这是一个很长的段落"));
assert_eq!(
output.change_report.updated_blocks,
vec!["imported_1".to_string()]
);
}
#[test]
fn ai_regression_page_reorder() {
let output = editor_ai_pipeline(
EditorInputKind::PlainText,
EditorAiScenario::PageReorder,
"第一页\n\n第二页",
)
.expect("ai regression reorder should succeed");
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
assert_eq!(
output.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}
#[test] #[test]
fn page_save_json_contract_uses_documents_save() { fn page_save_json_contract_uses_documents_save() {
let output = plan_page_save( let output = plan_page_save(
+113 -18
View File
@@ -1,12 +1,14 @@
use clap::{Args, Parser, Subcommand, ValueEnum}; use clap::{Args, Parser, Subcommand, ValueEnum};
use core_protocol::{InvocationKind, ToolExecutionMode}; use core_protocol::{InvocationKind, ToolExecutionMode};
use mnote_cli::{ use mnote_cli::{
execute_output, plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch, editor_ai_pipeline, editor_markdown_roundtrip, editor_session_demo, execute_output,
plan_mindmap_get, plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete, plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch, plan_mindmap_get,
plan_page_get, plan_page_move, plan_page_restore, plan_page_save, plan_page_title, plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete, plan_page_get,
plan_search_blocks, plan_search_documents, plan_sidebar_dataset, plan_tool_run, plan_page_move, plan_page_restore, plan_page_save, plan_page_title, plan_search_blocks,
plan_search_documents, plan_sidebar_dataset, plan_tool_run, render_editor_plain_output,
render_plain_output, BlockEmbedArgs, BlockMoveArgs, CliContext, CliError, CliJsonOutput, render_plain_output, BlockEmbedArgs, BlockMoveArgs, CliContext, CliError, CliJsonOutput,
PageCreateArgs, PageDeleteArgs, PageMoveArgs, PageRestoreArgs, EditorAiScenarioArg, EditorCliOutput, EditorInputKindArg, PageCreateArgs, PageDeleteArgs,
PageMoveArgs, PageRestoreArgs,
}; };
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -57,6 +59,7 @@ enum Commands {
Mindmap(MindmapCommand), Mindmap(MindmapCommand),
Search(SearchCommand), Search(SearchCommand),
Sidebar(SidebarCommand), Sidebar(SidebarCommand),
Editor(EditorCliCommand),
Tool(ToolCommand), Tool(ToolCommand),
} }
@@ -370,6 +373,43 @@ struct SidebarDatasetArgs {
workspace_id: String, workspace_id: String,
} }
#[derive(Args, Debug)]
struct EditorCliCommand {
#[command(subcommand)]
action: EditorAction,
}
#[derive(Subcommand, Debug)]
enum EditorAction {
MarkdownRoundtrip(EditorMarkdownRoundtripArgs),
SessionDemo(EditorSessionDemoArgs),
AiPipeline(EditorAiPipelineArgs),
}
#[derive(Args, Debug)]
struct EditorMarkdownRoundtripArgs {
#[arg(long)]
markdown: String,
}
#[derive(Args, Debug)]
struct EditorSessionDemoArgs {
#[arg(long)]
markdown: String,
}
#[derive(Args, Debug)]
struct EditorAiPipelineArgs {
#[arg(long, value_enum, default_value_t = EditorInputKindArg::PlainText)]
kind: EditorInputKindArg,
#[arg(long, value_enum, default_value_t = EditorAiScenarioArg::MeetingNotesToTodos)]
scenario: EditorAiScenarioArg,
#[arg(long)]
input: String,
}
#[derive(Args, Debug)] #[derive(Args, Debug)]
struct ToolCommand { struct ToolCommand {
#[command(subcommand)] #[command(subcommand)]
@@ -422,6 +462,11 @@ fn main() {
dry_run: cli.global.dry_run, dry_run: cli.global.dry_run,
}; };
enum CommandOutcome {
Bridge(CliJsonOutput),
Editor(EditorCliOutput),
}
let result = match cli.command { let result = match cli.command {
Commands::Page(command) => match command.action { Commands::Page(command) => match command.action {
PageAction::Get(args) => { PageAction::Get(args) => {
@@ -475,7 +520,8 @@ fn main() {
workspace_id: args.workspace_id.as_deref(), workspace_id: args.workspace_id.as_deref(),
}, },
), ),
}, }
.map(CommandOutcome::Bridge),
Commands::Block(command) => match command.action { Commands::Block(command) => match command.action {
BlockAction::Insert(args) => plan_block_insert( BlockAction::Insert(args) => plan_block_insert(
&context, &context,
@@ -512,7 +558,8 @@ fn main() {
target_block_id: args.target_block_id.as_deref(), target_block_id: args.target_block_id.as_deref(),
}, },
), ),
}, }
.map(CommandOutcome::Bridge),
Commands::Mindmap(command) => match command.action { Commands::Mindmap(command) => match command.action {
MindmapAction::Get(args) => plan_mindmap_get( MindmapAction::Get(args) => plan_mindmap_get(
&context, &context,
@@ -535,7 +582,8 @@ fn main() {
&args.mindmap_id, &args.mindmap_id,
&args.ops_json, &args.ops_json,
), ),
}, }
.map(CommandOutcome::Bridge),
Commands::Search(command) => match command.action { Commands::Search(command) => match command.action {
SearchAction::Documents(args) => plan_search_documents( SearchAction::Documents(args) => plan_search_documents(
&context, &context,
@@ -551,10 +599,22 @@ fn main() {
args.limit, args.limit,
args.cursor.as_deref(), args.cursor.as_deref(),
), ),
}, }
.map(CommandOutcome::Bridge),
Commands::Sidebar(command) => match command.action { Commands::Sidebar(command) => match command.action {
SidebarAction::Dataset(args) => plan_sidebar_dataset(&context, &args.workspace_id), SidebarAction::Dataset(args) => plan_sidebar_dataset(&context, &args.workspace_id),
}, }
.map(CommandOutcome::Bridge),
Commands::Editor(command) => match command.action {
EditorAction::MarkdownRoundtrip(args) => editor_markdown_roundtrip(&args.markdown),
EditorAction::SessionDemo(args) => editor_session_demo(&args.markdown),
EditorAction::AiPipeline(args) => editor_ai_pipeline(
to_editor_input_kind(args.kind),
to_editor_ai_scenario(args.scenario),
&args.input,
),
}
.map(CommandOutcome::Editor),
Commands::Tool(command) => match command.action { Commands::Tool(command) => match command.action {
ToolAction::Run(args) => plan_tool_run( ToolAction::Run(args) => plan_tool_run(
&context, &context,
@@ -563,18 +623,23 @@ fn main() {
to_execution_mode(args.mode), to_execution_mode(args.mode),
&args.args_json, &args.args_json,
), ),
},
}
.and_then(|output| {
if cli.global.execute {
execute_output(&output, &context)
} else {
Ok(output)
} }
.map(CommandOutcome::Bridge),
}
.and_then(|output| match output {
CommandOutcome::Bridge(output) => {
if cli.global.execute {
execute_output(&output, &context).map(CommandOutcome::Bridge)
} else {
Ok(CommandOutcome::Bridge(output))
}
}
CommandOutcome::Editor(output) => Ok(CommandOutcome::Editor(output)),
}); });
match result { match result {
Ok(output) => emit_success(&output, cli.global.json), Ok(CommandOutcome::Bridge(output)) => emit_success(&output, cli.global.json),
Ok(CommandOutcome::Editor(output)) => emit_editor_success(&output, cli.global.json),
Err(error) => emit_error(&error, cli.global.json), Err(error) => emit_error(&error, cli.global.json),
} }
} }
@@ -590,6 +655,17 @@ fn emit_success(output: &CliJsonOutput, json_mode: bool) {
} }
} }
fn emit_editor_success(output: &EditorCliOutput, json_mode: bool) {
if json_mode {
println!(
"{}",
serde_json::to_string_pretty(output).expect("editor CLI JSON 输出必须可序列化")
);
} else {
println!("{}", render_editor_plain_output(output));
}
}
fn emit_error(error: &CliError, json_mode: bool) -> ! { fn emit_error(error: &CliError, json_mode: bool) -> ! {
if json_mode { if json_mode {
println!( println!(
@@ -625,3 +701,22 @@ fn to_execution_mode(mode: ToolModeArg) -> ToolExecutionMode {
ToolModeArg::ExplainPlan => ToolExecutionMode::ExplainPlan, ToolModeArg::ExplainPlan => ToolExecutionMode::ExplainPlan,
} }
} }
fn to_editor_input_kind(kind: EditorInputKindArg) -> mnote_editor_core::EditorInputKind {
match kind {
EditorInputKindArg::PlainText => mnote_editor_core::EditorInputKind::PlainText,
EditorInputKindArg::Markdown => mnote_editor_core::EditorInputKind::Markdown,
}
}
fn to_editor_ai_scenario(scenario: EditorAiScenarioArg) -> mnote_editor_core::EditorAiScenario {
match scenario {
EditorAiScenarioArg::MeetingNotesToTodos => {
mnote_editor_core::EditorAiScenario::MeetingNotesToTodos
}
EditorAiScenarioArg::LongParagraphToTitle => {
mnote_editor_core::EditorAiScenario::LongParagraphToTitle
}
EditorAiScenarioArg::PageReorder => mnote_editor_core::EditorAiScenario::PageReorder,
}
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "mnote-editor-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
@@ -0,0 +1,292 @@
use crate::error::{CoreError, CoreResult};
use crate::model::{BlockType, DocumentBlock, DocumentModel};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditorCommand {
SetBlockType {
block_id: String,
block_type: BlockType,
},
ReplaceBlock {
block_id: String,
text: String,
},
InsertBlockAfter {
after_block_id: Option<String>,
block: DocumentBlock,
},
DeleteBlock {
block_id: String,
},
SplitBlock {
block_id: String,
offset: usize,
new_block_id: String,
},
MergeWithPrevious {
block_id: String,
},
MoveBlock {
block_id: String,
after_block_id: Option<String>,
},
IndentBlock {
block_id: String,
},
OutdentBlock {
block_id: String,
},
ToggleHeadingCollapse {
block_id: String,
},
}
#[derive(Debug, Default, Clone, Copy)]
pub struct CommandExecutor;
impl CommandExecutor {
pub fn apply(document: &mut DocumentModel, command: EditorCommand) -> CoreResult<()> {
match command {
EditorCommand::SetBlockType {
block_id,
block_type,
} => set_block_type(document, &block_id, block_type),
EditorCommand::ReplaceBlock { block_id, text } => {
replace_block(document, &block_id, text)
}
EditorCommand::InsertBlockAfter {
after_block_id,
block,
} => insert_block_after(document, after_block_id.as_deref(), block),
EditorCommand::DeleteBlock { block_id } => delete_block(document, &block_id),
EditorCommand::SplitBlock {
block_id,
offset,
new_block_id,
} => split_block(document, &block_id, offset, new_block_id),
EditorCommand::MergeWithPrevious { block_id } => {
merge_with_previous(document, &block_id)
}
EditorCommand::MoveBlock {
block_id,
after_block_id,
} => move_block(document, &block_id, after_block_id.as_deref()),
EditorCommand::IndentBlock { block_id } => indent_block(document, &block_id),
EditorCommand::OutdentBlock { block_id } => outdent_block(document, &block_id),
EditorCommand::ToggleHeadingCollapse { block_id } => {
toggle_heading_collapse(document, &block_id)
}
}
}
}
fn set_block_type(
document: &mut DocumentModel,
block_id: &str,
block_type: BlockType,
) -> CoreResult<()> {
let block = document
.block_mut(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
block.block_type = block_type;
Ok(())
}
fn replace_block(document: &mut DocumentModel, block_id: &str, text: String) -> CoreResult<()> {
let block = document
.block_mut(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
block.content.text = text;
Ok(())
}
fn insert_block_after(
document: &mut DocumentModel,
after_block_id: Option<&str>,
block: DocumentBlock,
) -> CoreResult<()> {
if document.contains_id(&block.id) {
return Err(CoreError::DuplicateBlockId(block.id));
}
let insert_index = match after_block_id {
Some(target_id) => {
let range = document
.subtree_range(target_id)
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
*range.end() + 1
}
None => document.blocks().len(),
};
document.blocks_mut().insert(insert_index, block);
Ok(())
}
fn delete_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
let range = document
.subtree_range(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
document.blocks_mut().drain(range);
Ok(())
}
fn split_block(
document: &mut DocumentModel,
block_id: &str,
offset: usize,
new_block_id: String,
) -> CoreResult<()> {
if document.contains_id(&new_block_id) {
return Err(CoreError::DuplicateBlockId(new_block_id));
}
let index = document
.index_of(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
let range = document
.subtree_range(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
let block = document.blocks()[index].clone();
let split_at = byte_index_for_char(&block.content.text, offset).ok_or_else(|| {
CoreError::InvalidSplitOffset {
block_id: block_id.to_string(),
offset,
}
})?;
let left = block.content.text[..split_at].to_string();
let right = block.content.text[split_at..].to_string();
{
let current = document
.block_mut(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
current.content.text = left;
}
let mut next_block = block;
next_block.id = new_block_id;
next_block.content.text = right;
next_block.collapsed = false;
document.blocks_mut().insert(*range.end() + 1, next_block);
Ok(())
}
fn merge_with_previous(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
let index = document
.index_of(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
if index == 0 {
return Err(CoreError::InvalidOperation("首块无法与上一块合并"));
}
let range = document
.subtree_range(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
if *range.start() != *range.end() {
return Err(CoreError::InvalidOperation(
"当前最小实现不支持带子树的 merge",
));
}
let previous_index = index - 1;
let text_to_append = document.blocks()[index].content.text.clone();
document.blocks_mut()[previous_index]
.content
.text
.push_str(&text_to_append);
document.blocks_mut().remove(index);
Ok(())
}
fn move_block(
document: &mut DocumentModel,
block_id: &str,
after_block_id: Option<&str>,
) -> CoreResult<()> {
let range = document
.subtree_range(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
let start = *range.start();
let end = *range.end();
let moved_ids: Vec<String> = document.blocks()[start..=end]
.iter()
.map(|block| block.id.clone())
.collect();
if let Some(target_id) = after_block_id {
if moved_ids.iter().any(|id| id == target_id) {
return Err(CoreError::InvalidOperation("不能把块移动到自己的子树后面"));
}
}
let moved_blocks: Vec<DocumentBlock> = document.blocks_mut().drain(start..=end).collect();
let insert_index = match after_block_id {
Some(target_id) => {
let target_range = document
.subtree_range(target_id)
.ok_or_else(|| CoreError::BlockNotFound(target_id.to_string()))?;
*target_range.end() + 1
}
None => 0,
};
document
.blocks_mut()
.splice(insert_index..insert_index, moved_blocks);
Ok(())
}
fn indent_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
let index = document
.index_of(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
if index == 0 {
return Err(CoreError::InvalidOperation("首块无法缩进"));
}
let previous_id = document.blocks()[index - 1].id.clone();
let range = document
.subtree_range(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
for position in *range.start()..=*range.end() {
document.blocks_mut()[position].indent += 1;
}
document.blocks_mut()[*range.start()].parent_id = Some(previous_id);
Ok(())
}
fn outdent_block(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
let block = document
.block(block_id)
.cloned()
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
let parent_id = block
.parent_id
.clone()
.ok_or(CoreError::InvalidOperation("当前块已在根层级"))?;
let next_parent_id = document
.block(&parent_id)
.and_then(|parent| parent.parent_id.clone());
let range = document
.subtree_range(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
for position in *range.start()..=*range.end() {
let current_indent = document.blocks()[position].indent;
if current_indent == 0 {
return Err(CoreError::InvalidOperation("根层级块不能继续反缩进"));
}
document.blocks_mut()[position].indent = current_indent - 1;
}
document.blocks_mut()[*range.start()].parent_id = next_parent_id;
Ok(())
}
fn toggle_heading_collapse(document: &mut DocumentModel, block_id: &str) -> CoreResult<()> {
let block = document
.block_mut(block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.to_string()))?;
block.collapsed = !block.collapsed;
Ok(())
}
fn byte_index_for_char(text: &str, offset: usize) -> Option<usize> {
if offset == text.chars().count() {
return Some(text.len());
}
text.char_indices().nth(offset).map(|(index, _)| index)
}
@@ -0,0 +1,29 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreError {
BlockNotFound(String),
DuplicateBlockId(String),
InvalidOperation(&'static str),
InvalidSplitOffset { block_id: String, offset: usize },
MarkdownParse(String),
}
impl Display for CoreError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::BlockNotFound(block_id) => write!(f, "未找到块: {block_id}"),
Self::DuplicateBlockId(block_id) => write!(f, "块 id 已存在: {block_id}"),
Self::InvalidOperation(message) => write!(f, "无效操作: {message}"),
Self::InvalidSplitOffset { block_id, offset } => {
write!(f, "块 {block_id} 的拆分位置无效: {offset}")
}
Self::MarkdownParse(message) => write!(f, "Markdown 解析失败: {message}"),
}
}
}
impl Error for CoreError {}
pub type CoreResult<T> = Result<T, CoreError>;
@@ -0,0 +1,50 @@
use crate::command::{CommandExecutor, EditorCommand};
use crate::error::CoreResult;
use crate::model::DocumentModel;
#[derive(Debug, Clone)]
pub struct EditorSession {
document: DocumentModel,
undo_stack: Vec<DocumentModel>,
redo_stack: Vec<DocumentModel>,
}
impl EditorSession {
pub fn new(document: DocumentModel) -> Self {
Self {
document,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
}
}
pub fn document(&self) -> &DocumentModel {
&self.document
}
pub fn apply_command(&mut self, command: EditorCommand) -> CoreResult<()> {
let snapshot = self.document.clone();
CommandExecutor::apply(&mut self.document, command)?;
self.undo_stack.push(snapshot);
self.redo_stack.clear();
Ok(())
}
pub fn undo(&mut self) -> bool {
let Some(previous) = self.undo_stack.pop() else {
return false;
};
self.redo_stack.push(self.document.clone());
self.document = previous;
true
}
pub fn redo(&mut self) -> bool {
let Some(next) = self.redo_stack.pop() else {
return false;
};
self.undo_stack.push(self.document.clone());
self.document = next;
true
}
}
+20
View File
@@ -0,0 +1,20 @@
pub mod command;
pub mod error;
pub mod history;
pub mod markdown;
pub mod model;
pub mod pipeline;
pub mod projection;
pub use command::{CommandExecutor, EditorCommand};
pub use error::{CoreError, CoreResult};
pub use history::EditorSession;
pub use markdown::{export_markdown, import_markdown, import_plain_text};
pub use model::{
BlockContent, BlockReference, BlockType, DocumentBlock, DocumentModel, ReferenceKind,
};
pub use pipeline::{
apply_ai_pipeline, BlockSnapshot, CommandAuditRecord, EditorAiScenario, EditorChangeReport,
EditorInputKind, EditorPipelineRequest, EditorPipelineResult,
};
pub use projection::{OutlineEntry, VisibilitySnapshot};
@@ -0,0 +1,263 @@
use crate::error::{CoreError, CoreResult};
use crate::model::{BlockType, DocumentBlock, DocumentModel, ReferenceKind};
pub fn import_markdown(input: &str) -> CoreResult<DocumentModel> {
let mut blocks = Vec::new();
let mut ancestry: Vec<String> = Vec::new();
let mut next_id = 1usize;
let mut lines = input.lines().peekable();
while let Some(line) = lines.next() {
if line.trim().is_empty() {
continue;
}
if line.trim_start().starts_with("```") {
let language = line
.trim_start()
.trim_start_matches("```")
.trim()
.to_string();
let mut code_lines = Vec::new();
let mut closed = false;
for next in lines.by_ref() {
if next.trim_start().starts_with("```") {
closed = true;
break;
}
code_lines.push(next.to_string());
}
if !closed {
return Err(CoreError::MarkdownParse("代码块缺少结束围栏".into()));
}
ancestry.clear();
let mut block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::CodeBlock)
.with_text(code_lines.join("\n"));
if !language.is_empty() {
block = block.with_language(language);
}
blocks.push(block);
continue;
}
let indent = count_indent(line);
while ancestry.len() > indent as usize {
ancestry.pop();
}
let trimmed = line.trim_start();
let mut block = if let Some((level, title)) = parse_heading(trimmed) {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Heading)
.with_heading_level(level)
.with_text(title)
} else if let Some((checked, text)) = parse_todo(trimmed) {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Todo)
.with_checked(checked)
.with_text(text)
} else if let Some(text) = parse_bullet(trimmed) {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BulletListItem)
.with_text(text)
} else if let Some(text) = parse_numbered(trimmed) {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::NumberedListItem)
.with_text(text)
} else if let Some(text) = trimmed.strip_prefix("> ") {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Quote).with_text(text)
} else if trimmed == "---" {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Divider)
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "[[", "]]") {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::PageReference)
.with_reference(ReferenceKind::Page, target_id, label)
} else if let Some((target_id, label)) = parse_reference_token(trimmed, "((", "))") {
ancestry.clear();
DocumentBlock::new(next_block_id(&mut next_id), BlockType::BlockReference)
.with_reference(ReferenceKind::Block, target_id, label)
} else {
DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph).with_text(trimmed)
};
if indent > 0 {
let parent_id = ancestry
.last()
.cloned()
.ok_or(CoreError::MarkdownParse("缩进层级缺少父块".into()))?;
block = block.with_parent(parent_id, indent);
}
ancestry.push(block.id.clone());
blocks.push(block);
}
Ok(DocumentModel::new(blocks))
}
pub fn import_plain_text(input: &str) -> CoreResult<DocumentModel> {
let mut blocks = Vec::new();
let normalized = input.replace("\r\n", "\n");
let mut next_id = 1usize;
for paragraph in normalized
.split("\n\n")
.map(str::trim)
.filter(|value| !value.is_empty())
{
let block = DocumentBlock::new(next_block_id(&mut next_id), BlockType::Paragraph)
.with_text(paragraph.replace('\n', " "));
blocks.push(block);
}
Ok(DocumentModel::new(blocks))
}
pub fn export_markdown(document: &DocumentModel) -> String {
let mut lines = Vec::new();
for block in document.blocks() {
let indent = " ".repeat(block.indent as usize);
match block.block_type {
BlockType::Heading => {
let level = block.heading_level.unwrap_or(1).clamp(1, 6);
lines.push(format!(
"{indent}{} {}",
"#".repeat(level as usize),
block.content.text
));
}
BlockType::BulletListItem => {
lines.push(format!("{indent}- {}", block.content.text));
}
BlockType::NumberedListItem => {
lines.push(format!("{indent}1. {}", block.content.text));
}
BlockType::Todo => {
let marker = if block.checked.unwrap_or(false) {
"x"
} else {
" "
};
lines.push(format!("{indent}- [{marker}] {}", block.content.text));
}
BlockType::Quote => {
lines.push(format!("{indent}> {}", block.content.text));
}
BlockType::Divider => {
lines.push(format!("{indent}---"));
}
BlockType::CodeBlock => {
let fence = match block.content.language.as_deref() {
Some(language) if !language.is_empty() => format!("{indent}```{language}"),
_ => format!("{indent}```"),
};
lines.push(fence);
for code_line in block.content.text.lines() {
lines.push(format!("{indent}{code_line}"));
}
lines.push(format!("{indent}```"));
}
BlockType::PageReference => {
lines.push(format!(
"{indent}{}",
render_reference_token(block.content.reference.as_ref(), "[[", "]]")
));
}
BlockType::BlockReference => {
lines.push(format!(
"{indent}{}",
render_reference_token(block.content.reference.as_ref(), "((", "))")
));
}
_ => {
lines.push(format!("{indent}{}", block.content.text));
}
}
}
lines.join("\n")
}
fn next_block_id(next_id: &mut usize) -> String {
let current = *next_id;
*next_id += 1;
format!("imported_{current}")
}
fn count_indent(line: &str) -> u16 {
let mut spaces = 0usize;
for ch in line.chars() {
match ch {
' ' => spaces += 1,
'\t' => spaces += 2,
_ => break,
}
}
(spaces / 2) as u16
}
fn parse_heading(line: &str) -> Option<(u8, &str)> {
let level = line.chars().take_while(|ch| *ch == '#').count();
if level == 0 || level > 6 {
return None;
}
let title = line.get(level + 1..)?;
if !line.as_bytes().get(level).is_some_and(|ch| *ch == b' ') {
return None;
}
Some((level as u8, title))
}
fn parse_todo(line: &str) -> Option<(bool, &str)> {
if let Some(rest) = line.strip_prefix("- [ ] ") {
return Some((false, rest));
}
if let Some(rest) = line.strip_prefix("- [x] ") {
return Some((true, rest));
}
if let Some(rest) = line.strip_prefix("- [X] ") {
return Some((true, rest));
}
None
}
fn parse_bullet(line: &str) -> Option<&str> {
line.strip_prefix("- ").or_else(|| line.strip_prefix("* "))
}
fn parse_numbered(line: &str) -> Option<&str> {
let dot_index = line.find(". ")?;
if line[..dot_index].chars().all(|ch| ch.is_ascii_digit()) {
line.get(dot_index + 2..)
} else {
None
}
}
fn parse_reference_token(
line: &str,
prefix: &str,
suffix: &str,
) -> Option<(String, Option<String>)> {
let body = line.strip_prefix(prefix)?.strip_suffix(suffix)?;
let mut parts = body.splitn(2, '|');
let target_id = parts.next()?.trim();
if target_id.is_empty() {
return None;
}
let label = parts.next().map(|value| value.trim().to_string());
Some((
target_id.to_string(),
label.filter(|value| !value.is_empty()),
))
}
fn render_reference_token(
reference: Option<&crate::model::BlockReference>,
prefix: &str,
suffix: &str,
) -> String {
match reference {
Some(reference) => match reference.label.as_deref() {
Some(label) if !label.is_empty() => {
format!("{prefix}{}|{label}{suffix}", reference.target_id)
}
_ => format!("{prefix}{}{suffix}", reference.target_id),
},
None => format!("{prefix}{suffix}"),
}
}
+231
View File
@@ -0,0 +1,231 @@
use serde::{Deserialize, Serialize};
use std::ops::RangeInclusive;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockType {
Paragraph,
Heading,
BulletListItem,
NumberedListItem,
Todo,
Quote,
Divider,
CodeBlock,
PageReference,
BlockReference,
MediaPlaceholder,
ProgressPlaceholder,
}
impl BlockType {
pub fn from_editor_label(label: &str) -> Option<Self> {
match label {
"paragraph" => Some(Self::Paragraph),
"heading" => Some(Self::Heading),
"bullet_list_item" => Some(Self::BulletListItem),
"numbered_list_item" => Some(Self::NumberedListItem),
"todo" => Some(Self::Todo),
"quote" => Some(Self::Quote),
"divider" => Some(Self::Divider),
"code_block" => Some(Self::CodeBlock),
"page_reference" => Some(Self::PageReference),
"block_reference" => Some(Self::BlockReference),
"media_placeholder" => Some(Self::MediaPlaceholder),
"progress_placeholder" => Some(Self::ProgressPlaceholder),
_ => None,
}
}
pub fn as_editor_label(&self) -> &'static str {
match self {
Self::Paragraph => "paragraph",
Self::Heading => "heading",
Self::BulletListItem => "bullet_list_item",
Self::NumberedListItem => "numbered_list_item",
Self::Todo => "todo",
Self::Quote => "quote",
Self::Divider => "divider",
Self::CodeBlock => "code_block",
Self::PageReference => "page_reference",
Self::BlockReference => "block_reference",
Self::MediaPlaceholder => "media_placeholder",
Self::ProgressPlaceholder => "progress_placeholder",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReferenceKind {
Page,
Block,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockReference {
pub kind: ReferenceKind,
pub target_id: String,
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BlockContent {
pub text: String,
pub language: Option<String>,
pub reference: Option<BlockReference>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentBlock {
pub id: String,
pub block_type: BlockType,
pub parent_id: Option<String>,
pub indent: u16,
pub collapsed: bool,
pub heading_level: Option<u8>,
pub checked: Option<bool>,
pub content: BlockContent,
}
impl DocumentBlock {
pub fn new(id: impl Into<String>, block_type: BlockType) -> Self {
Self {
id: id.into(),
block_type,
parent_id: None,
indent: 0,
collapsed: false,
heading_level: None,
checked: None,
content: BlockContent::default(),
}
}
pub fn with_parent(mut self, parent_id: impl Into<String>, indent: u16) -> Self {
self.parent_id = Some(parent_id.into());
self.indent = indent;
self
}
pub fn with_heading_level(mut self, level: u8) -> Self {
self.heading_level = Some(level);
self
}
pub fn with_text(mut self, text: impl Into<String>) -> Self {
self.content.text = text.into();
self
}
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = Some(checked);
self
}
pub fn with_language(mut self, language: impl Into<String>) -> Self {
self.content.language = Some(language.into());
self
}
pub fn with_reference(
mut self,
kind: ReferenceKind,
target_id: impl Into<String>,
label: Option<String>,
) -> Self {
self.content.reference = Some(BlockReference {
kind,
target_id: target_id.into(),
label,
});
self
}
pub fn with_collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
pub fn set_block_type(mut self, block_type: BlockType) -> Self {
self.block_type = block_type;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct DocumentModel {
blocks: Vec<DocumentBlock>,
}
impl DocumentModel {
pub fn new(blocks: Vec<DocumentBlock>) -> Self {
Self { blocks }
}
pub fn empty() -> Self {
Self::default()
}
pub fn blocks(&self) -> &[DocumentBlock] {
&self.blocks
}
pub(crate) fn blocks_mut(&mut self) -> &mut Vec<DocumentBlock> {
&mut self.blocks
}
pub fn push(&mut self, block: DocumentBlock) {
self.blocks.push(block);
}
pub fn block(&self, id: &str) -> Option<&DocumentBlock> {
self.blocks.iter().find(|block| block.id == id)
}
pub(crate) fn block_mut(&mut self, id: &str) -> Option<&mut DocumentBlock> {
self.blocks.iter_mut().find(|block| block.id == id)
}
pub fn contains_id(&self, id: &str) -> bool {
self.block(id).is_some()
}
pub fn index_of(&self, id: &str) -> Option<usize> {
self.blocks.iter().position(|block| block.id == id)
}
pub fn children_of<'a>(&'a self, parent_id: Option<&str>) -> Vec<&'a DocumentBlock> {
self.blocks
.iter()
.filter(|block| block.parent_id.as_deref() == parent_id)
.collect()
}
pub fn is_descendant_of(&self, block_id: &str, ancestor_id: &str) -> bool {
let mut current_parent = self
.block(block_id)
.and_then(|block| block.parent_id.as_deref());
while let Some(parent_id) = current_parent {
if parent_id == ancestor_id {
return true;
}
current_parent = self
.block(parent_id)
.and_then(|parent| parent.parent_id.as_deref());
}
false
}
pub fn subtree_range(&self, block_id: &str) -> Option<RangeInclusive<usize>> {
let start = self.index_of(block_id)?;
let mut end = start;
for next_index in (start + 1)..self.blocks.len() {
if self.is_descendant_of(&self.blocks[next_index].id, block_id) {
end = next_index;
} else {
break;
}
}
Some(start..=end)
}
}
@@ -0,0 +1,267 @@
use crate::command::{CommandExecutor, EditorCommand};
use crate::error::{CoreError, CoreResult};
use crate::markdown::{import_markdown, import_plain_text};
use crate::model::{BlockType, DocumentBlock, DocumentModel};
use crate::projection::VisibilitySnapshot;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EditorInputKind {
PlainText,
Markdown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EditorAiScenario {
MeetingNotesToTodos,
LongParagraphToTitle,
PageReorder,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorPipelineRequest {
pub kind: EditorInputKind,
pub scenario: EditorAiScenario,
pub input: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BlockSnapshot {
pub block_id: String,
pub block_type: String,
pub text: String,
pub parent_id: Option<String>,
pub indent: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CommandAuditRecord {
pub command: String,
pub target_block_id: Option<String>,
pub before: Option<BlockSnapshot>,
pub after: Option<BlockSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorChangeReport {
pub created_blocks: Vec<String>,
pub updated_blocks: Vec<String>,
pub moved_blocks: Vec<String>,
pub removed_blocks: Vec<String>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EditorPipelineResult {
pub ok: bool,
pub scenario: EditorAiScenario,
pub input_kind: EditorInputKind,
pub document: DocumentModel,
pub audit: Vec<CommandAuditRecord>,
pub change_report: EditorChangeReport,
pub visible_block_ids: Vec<String>,
pub outline_titles: Vec<String>,
pub markdown: String,
}
pub fn apply_ai_pipeline(request: EditorPipelineRequest) -> CoreResult<EditorPipelineResult> {
let mut document = match request.kind {
EditorInputKind::PlainText => import_plain_text(&request.input)?,
EditorInputKind::Markdown => import_markdown(&request.input)?,
};
if document.blocks().is_empty() {
document.push(DocumentBlock::new("block_1", BlockType::Paragraph).with_text(""));
}
let mut audit = Vec::new();
let mut change_report = EditorChangeReport {
created_blocks: Vec::new(),
updated_blocks: Vec::new(),
moved_blocks: Vec::new(),
removed_blocks: Vec::new(),
notes: Vec::new(),
};
match request.scenario {
EditorAiScenario::MeetingNotesToTodos => {
apply_meeting_notes_to_todos(&mut document, &mut audit, &mut change_report)?
}
EditorAiScenario::LongParagraphToTitle => {
apply_long_paragraph_to_title(&mut document, &mut audit, &mut change_report)?
}
EditorAiScenario::PageReorder => {
apply_page_reorder(&mut document, &mut audit, &mut change_report)?
}
}
let projection = VisibilitySnapshot::derive(&document);
let markdown = crate::markdown::export_markdown(&document);
let outline_titles = projection
.outline
.iter()
.map(|entry| entry.title.clone())
.collect();
Ok(EditorPipelineResult {
ok: true,
scenario: request.scenario,
input_kind: request.kind,
document,
audit,
change_report,
visible_block_ids: projection.visible_block_ids,
outline_titles,
markdown,
})
}
fn apply_meeting_notes_to_todos(
document: &mut DocumentModel,
audit: &mut Vec<CommandAuditRecord>,
report: &mut EditorChangeReport,
) -> CoreResult<()> {
for index in 0..document.blocks().len() {
let is_note = document.blocks()[index].block_type == BlockType::Paragraph
&& document.blocks()[index].content.text.contains('待')
&& document.blocks()[index].content.text.contains('办');
if is_note {
let block_id = document.blocks()[index].id.clone();
let before = snapshot_block(document.blocks()[index].clone());
CommandExecutor::apply(
document,
EditorCommand::SetBlockType {
block_id: block_id.clone(),
block_type: BlockType::Todo,
},
)?;
let block = document
.block(&block_id)
.ok_or_else(|| CoreError::BlockNotFound(block_id.clone()))?;
let after = snapshot_block(block.clone());
audit.push(CommandAuditRecord {
command: "set_block_type".into(),
target_block_id: Some(block_id.clone()),
before: Some(before),
after: Some(after),
});
report.updated_blocks.push(block_id);
}
}
report.notes.push("已将疑似会议待办段落转为 todo".into());
Ok(())
}
fn apply_long_paragraph_to_title(
document: &mut DocumentModel,
audit: &mut Vec<CommandAuditRecord>,
report: &mut EditorChangeReport,
) -> CoreResult<()> {
let Some(first_id) = document.blocks().first().map(|block| block.id.clone()) else {
return Ok(());
};
let before = snapshot_block(
document
.block(&first_id)
.cloned()
.ok_or_else(|| CoreError::BlockNotFound(first_id.clone()))?,
);
let title = document
.block(&first_id)
.map(|block| {
block
.content
.text
.split_whitespace()
.take(8)
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default();
CommandExecutor::apply(
document,
EditorCommand::SetBlockType {
block_id: first_id.clone(),
block_type: BlockType::Heading,
},
)?;
if let Some(block) = document.block_mut(&first_id) {
block.heading_level = Some(1);
block.content.text = if title.is_empty() {
"提炼标题".into()
} else {
title
};
}
let after = snapshot_block(
document
.block(&first_id)
.cloned()
.ok_or_else(|| CoreError::BlockNotFound(first_id.clone()))?,
);
audit.push(CommandAuditRecord {
command: "set_block_type".into(),
target_block_id: Some(first_id.clone()),
before: Some(before),
after: Some(after),
});
report.updated_blocks.push(first_id);
report.notes.push("已将长段落提炼为标题".into());
Ok(())
}
fn apply_page_reorder(
document: &mut DocumentModel,
audit: &mut Vec<CommandAuditRecord>,
report: &mut EditorChangeReport,
) -> CoreResult<()> {
if document.blocks().len() < 2 {
report.notes.push("块数不足,跳过重排".into());
return Ok(());
}
let first = document.blocks()[0].id.clone();
let second = document.blocks()[1].id.clone();
let before_first = snapshot_block(
document
.block(&first)
.cloned()
.ok_or_else(|| CoreError::BlockNotFound(first.clone()))?,
);
CommandExecutor::apply(
document,
EditorCommand::MoveBlock {
block_id: first.clone(),
after_block_id: Some(second.clone()),
},
)?;
let after_first = snapshot_block(
document
.block(&first)
.cloned()
.ok_or_else(|| CoreError::BlockNotFound(first.clone()))?,
);
audit.push(CommandAuditRecord {
command: "move_block".into(),
target_block_id: Some(first.clone()),
before: Some(before_first),
after: Some(after_first),
});
report.moved_blocks.push(first);
report.notes.push("已执行页面重排".into());
Ok(())
}
fn snapshot_block(block: DocumentBlock) -> BlockSnapshot {
BlockSnapshot {
block_id: block.id,
block_type: block.block_type.as_editor_label().into(),
text: block.content.text,
parent_id: block.parent_id,
indent: block.indent,
}
}
@@ -0,0 +1,55 @@
use crate::model::{BlockType, DocumentBlock, DocumentModel};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutlineEntry {
pub block_id: String,
pub level: u8,
pub title: String,
pub depth: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisibilitySnapshot {
pub visible_block_ids: Vec<String>,
pub outline: Vec<OutlineEntry>,
}
impl VisibilitySnapshot {
pub fn derive(document: &DocumentModel) -> Self {
let mut hidden_ancestor_ids: Vec<String> = Vec::new();
let mut visible_block_ids = Vec::new();
let mut outline = Vec::new();
for block in document.blocks() {
hidden_ancestor_ids.retain(|ancestor_id| is_ancestor_of(document, ancestor_id, block));
let visible = hidden_ancestor_ids.is_empty();
if visible {
visible_block_ids.push(block.id.clone());
}
if matches!(block.block_type, BlockType::Heading) {
let level = block.heading_level.unwrap_or(1);
if visible {
outline.push(OutlineEntry {
block_id: block.id.clone(),
level,
title: block.content.text.clone(),
depth: block.indent as usize,
});
}
if block.collapsed {
hidden_ancestor_ids.push(block.id.clone());
}
}
}
Self {
visible_block_ids,
outline,
}
}
}
fn is_ancestor_of(document: &DocumentModel, ancestor_id: &str, block: &DocumentBlock) -> bool {
document.is_descendant_of(&block.id, ancestor_id)
}
@@ -0,0 +1,177 @@
use mnote_editor_core::{
BlockType, CommandExecutor, DocumentBlock, DocumentModel, EditorCommand, VisibilitySnapshot,
};
#[test]
fn command_executor_replaces_inserts_and_deletes_blocks() {
let mut document = DocumentModel::new(vec![
DocumentBlock::new("block_a", BlockType::Paragraph).with_text("hello"),
DocumentBlock::new("block_b", BlockType::Paragraph).with_text("world"),
]);
CommandExecutor::apply(
&mut document,
EditorCommand::ReplaceBlock {
block_id: "block_a".into(),
text: "hello mnote".into(),
},
)
.expect("replace should succeed");
CommandExecutor::apply(
&mut document,
EditorCommand::InsertBlockAfter {
after_block_id: Some("block_a".into()),
block: DocumentBlock::new("block_h", BlockType::Heading)
.with_heading_level(2)
.with_text("section"),
},
)
.expect("insert should succeed");
CommandExecutor::apply(
&mut document,
EditorCommand::DeleteBlock {
block_id: "block_b".into(),
},
)
.expect("delete should succeed");
assert_eq!(
document
.blocks()
.iter()
.map(|block| block.id.as_str())
.collect::<Vec<_>>(),
vec!["block_a", "block_h"]
);
assert_eq!(
document.block("block_a").expect("block_a").content.text,
"hello mnote"
);
}
#[test]
fn command_executor_splits_merges_moves_and_reindents_blocks() {
let mut document = DocumentModel::new(vec![
DocumentBlock::new("block_h", BlockType::Heading)
.with_heading_level(1)
.with_text("root"),
DocumentBlock::new("block_b", BlockType::Paragraph).with_text("AlphaBeta"),
DocumentBlock::new("block_c", BlockType::Paragraph).with_text("Tail"),
]);
CommandExecutor::apply(
&mut document,
EditorCommand::SplitBlock {
block_id: "block_b".into(),
offset: 5,
new_block_id: "block_d".into(),
},
)
.expect("split should succeed");
assert_eq!(
document.block("block_b").expect("block_b").content.text,
"Alpha"
);
assert_eq!(
document.block("block_d").expect("block_d").content.text,
"Beta"
);
CommandExecutor::apply(
&mut document,
EditorCommand::MergeWithPrevious {
block_id: "block_d".into(),
},
)
.expect("merge should succeed");
assert_eq!(
document.block("block_b").expect("block_b").content.text,
"AlphaBeta"
);
CommandExecutor::apply(
&mut document,
EditorCommand::MoveBlock {
block_id: "block_c".into(),
after_block_id: Some("block_h".into()),
},
)
.expect("move should succeed");
assert_eq!(
document
.blocks()
.iter()
.map(|block| block.id.as_str())
.collect::<Vec<_>>(),
vec!["block_h", "block_c", "block_b"]
);
CommandExecutor::apply(
&mut document,
EditorCommand::IndentBlock {
block_id: "block_b".into(),
},
)
.expect("indent should succeed");
let indented = document.block("block_b").expect("block_b after indent");
assert_eq!(indented.parent_id.as_deref(), Some("block_c"));
assert_eq!(indented.indent, 1);
CommandExecutor::apply(
&mut document,
EditorCommand::OutdentBlock {
block_id: "block_b".into(),
},
)
.expect("outdent should succeed");
let outdented = document.block("block_b").expect("block_b after outdent");
assert_eq!(outdented.parent_id, None);
assert_eq!(outdented.indent, 0);
}
#[test]
fn command_executor_toggles_heading_collapse_visibility() {
let mut document = DocumentModel::new(vec![
DocumentBlock::new("heading_root", BlockType::Heading)
.with_heading_level(1)
.with_text("root"),
DocumentBlock::new("child_para", BlockType::Paragraph)
.with_parent("heading_root", 1)
.with_text("hidden after collapse"),
DocumentBlock::new("tail", BlockType::Paragraph).with_text("tail"),
]);
CommandExecutor::apply(
&mut document,
EditorCommand::ToggleHeadingCollapse {
block_id: "heading_root".into(),
},
)
.expect("toggle collapse should succeed");
assert!(document.block("heading_root").expect("heading").collapsed);
let projection = VisibilitySnapshot::derive(&document);
assert_eq!(
projection.visible_block_ids,
vec!["heading_root".to_string(), "tail".to_string()]
);
}
#[test]
fn command_executor_changes_block_type() {
let mut document =
DocumentModel::new(vec![DocumentBlock::new("block_a", BlockType::Paragraph)]);
CommandExecutor::apply(
&mut document,
EditorCommand::SetBlockType {
block_id: "block_a".into(),
block_type: BlockType::Todo,
},
)
.expect("set block type should succeed");
assert_eq!(
document.block("block_a").expect("block_a").block_type,
BlockType::Todo
);
}
@@ -0,0 +1,46 @@
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, VisibilitySnapshot};
#[test]
fn document_model_preserves_parent_structure_and_outline_visibility() {
let document = DocumentModel::new(vec![
DocumentBlock::new("heading_root", BlockType::Heading)
.with_heading_level(1)
.with_text("根标题")
.with_collapsed(true),
DocumentBlock::new("paragraph_hidden", BlockType::Paragraph)
.with_parent("heading_root", 1)
.with_text("折叠后应隐藏"),
DocumentBlock::new("heading_visible", BlockType::Heading)
.with_heading_level(2)
.with_text("次级标题"),
DocumentBlock::new("todo_visible", BlockType::Todo)
.with_parent("heading_visible", 1)
.with_text("仍然可见"),
]);
assert_eq!(document.blocks().len(), 4);
assert_eq!(
document
.children_of(Some("heading_root"))
.into_iter()
.map(|block| block.id.as_str())
.collect::<Vec<_>>(),
vec!["paragraph_hidden"]
);
let projection = VisibilitySnapshot::derive(&document);
assert_eq!(
projection.visible_block_ids,
vec![
"heading_root".to_string(),
"heading_visible".to_string(),
"todo_visible".to_string(),
]
);
assert_eq!(projection.outline.len(), 2);
assert_eq!(projection.outline[0].block_id, "heading_root");
assert_eq!(projection.outline[0].title, "根标题");
assert_eq!(projection.outline[1].block_id, "heading_visible");
assert_eq!(projection.outline[1].level, 2);
}
@@ -0,0 +1,75 @@
use mnote_editor_core::{BlockType, DocumentBlock, DocumentModel, EditorCommand, EditorSession};
#[test]
fn history_undo_redo_restores_command_sequence() {
let mut session = EditorSession::new(DocumentModel::new(vec![DocumentBlock::new(
"block_a",
BlockType::Paragraph,
)
.with_text("hello")]));
session
.apply_command(EditorCommand::ReplaceBlock {
block_id: "block_a".into(),
text: "hello world".into(),
})
.expect("replace should succeed");
session
.apply_command(EditorCommand::InsertBlockAfter {
after_block_id: Some("block_a".into()),
block: DocumentBlock::new("block_b", BlockType::Paragraph).with_text("tail"),
})
.expect("insert should succeed");
assert_eq!(session.document().blocks().len(), 2);
assert!(session.undo());
assert_eq!(session.document().blocks().len(), 1);
assert_eq!(
session
.document()
.block("block_a")
.expect("block_a after undo")
.content
.text,
"hello world"
);
assert!(session.undo());
assert_eq!(
session
.document()
.block("block_a")
.expect("block_a after second undo")
.content
.text,
"hello"
);
assert!(session.redo());
assert!(session.redo());
assert_eq!(session.document().blocks().len(), 2);
}
#[test]
fn history_clears_redo_after_new_command() {
let mut session = EditorSession::new(DocumentModel::new(vec![DocumentBlock::new(
"block_a",
BlockType::Paragraph,
)
.with_text("hello")]));
session
.apply_command(EditorCommand::ReplaceBlock {
block_id: "block_a".into(),
text: "hello world".into(),
})
.expect("replace should succeed");
assert!(session.undo());
session
.apply_command(EditorCommand::InsertBlockAfter {
after_block_id: Some("block_a".into()),
block: DocumentBlock::new("block_b", BlockType::Paragraph).with_text("fresh"),
})
.expect("insert should succeed");
assert!(!session.redo());
assert_eq!(session.document().blocks().len(), 2);
}
@@ -0,0 +1,95 @@
use mnote_editor_core::{
export_markdown, import_markdown, import_plain_text, BlockType, ReferenceKind,
};
#[test]
fn markdown_import_export_round_trip_preserves_block_kinds() {
let source = r#"# 根标题
-
- [x]
>
---
```rust
fn main() {}
```
[[page_1|]]
((block_1|))
"#;
let document = import_markdown(source).expect("markdown import should succeed");
assert_eq!(
document
.blocks()
.iter()
.map(|block| block.block_type.clone())
.collect::<Vec<_>>(),
vec![
BlockType::Heading,
BlockType::BulletListItem,
BlockType::Todo,
BlockType::Quote,
BlockType::Divider,
BlockType::CodeBlock,
BlockType::PageReference,
BlockType::BlockReference,
BlockType::Paragraph,
]
);
let exported = export_markdown(&document);
let reparsed = import_markdown(&exported).expect("markdown re-import should succeed");
assert_eq!(
reparsed
.blocks()
.iter()
.map(|block| block.block_type.clone())
.collect::<Vec<_>>(),
document
.blocks()
.iter()
.map(|block| block.block_type.clone())
.collect::<Vec<_>>()
);
}
#[test]
fn markdown_export_renders_reference_tokens_and_code_fence() {
let source = r#"[[page_42|设计文档]]
((block_99|))
```ts
console.log("ok");
```
"#;
let document = import_markdown(source).expect("markdown import should succeed");
let page_ref = document.blocks()[0]
.content
.reference
.as_ref()
.expect("page reference");
assert_eq!(page_ref.kind, ReferenceKind::Page);
let block_ref = document.blocks()[1]
.content
.reference
.as_ref()
.expect("block reference");
assert_eq!(block_ref.kind, ReferenceKind::Block);
let exported = export_markdown(&document);
assert!(exported.contains("[[page_42|设计文档]]"));
assert!(exported.contains("((block_99|引用块))"));
assert!(exported.contains("```ts"));
assert!(exported.contains("console.log(\"ok\");"));
}
#[test]
fn plain_text_import_splits_paragraphs_and_normalizes_line_breaks() {
let document =
import_plain_text("第一段\n继续\n\n第二段").expect("plain text import should succeed");
assert_eq!(document.blocks().len(), 2);
assert_eq!(document.blocks()[0].content.text, "第一段 继续");
assert_eq!(document.blocks()[1].content.text, "第二段");
assert_eq!(document.blocks()[0].id, "imported_1");
assert_eq!(document.blocks()[1].id, "imported_2");
}
@@ -0,0 +1,63 @@
use mnote_editor_core::{
apply_ai_pipeline, EditorAiScenario, EditorInputKind, EditorPipelineRequest,
};
#[test]
fn ai_pipeline_turns_meeting_notes_into_todos() {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind: EditorInputKind::PlainText,
scenario: EditorAiScenario::MeetingNotesToTodos,
input: "待办:整理会议纪要\n普通段落".into(),
})
.expect("pipeline should succeed");
assert_eq!(
result.document.blocks()[0].block_type.as_editor_label(),
"todo"
);
assert_eq!(result.audit.len(), 1);
assert!(result
.change_report
.updated_blocks
.contains(&"imported_1".to_string()));
assert!(result.markdown.contains("待办:整理会议纪要"));
}
#[test]
fn ai_pipeline_refines_long_paragraph_to_title() {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind: EditorInputKind::PlainText,
scenario: EditorAiScenario::LongParagraphToTitle,
input: "这是一个很长的段落 用于提炼 标题".into(),
})
.expect("pipeline should succeed");
assert_eq!(
result.document.blocks()[0].block_type.as_editor_label(),
"heading"
);
assert_eq!(result.document.blocks()[0].heading_level, Some(1));
assert_eq!(result.audit.len(), 1);
assert!(result
.change_report
.updated_blocks
.contains(&"imported_1".to_string()));
}
#[test]
fn ai_pipeline_reorders_first_block_after_second() {
let result = apply_ai_pipeline(EditorPipelineRequest {
kind: EditorInputKind::PlainText,
scenario: EditorAiScenario::PageReorder,
input: "第一页\n\n第二页".into(),
})
.expect("pipeline should succeed");
assert_eq!(result.document.blocks()[0].content.text, "第二页");
assert_eq!(result.document.blocks()[1].content.text, "第一页");
assert_eq!(result.audit.len(), 1);
assert_eq!(
result.change_report.moved_blocks,
vec!["imported_1".to_string()]
);
}
+2
View File
@@ -11,9 +11,11 @@ bridge-runtime = { path = "../bridge-runtime" }
core-protocol = { path = "../core-protocol" } core-protocol = { path = "../core-protocol" }
futures-util = "0.3" futures-util = "0.3"
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] } leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
mnote-editor-core = { path = "../mnote-editor-core" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
storage-convex-bridge = { path = "../storage-convex-bridge" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
tower-http = { version = "0.6", features = ["trace"] } tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1" tracing = "0.1"
@@ -0,0 +1,661 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::execute_runtime_command_via_convex;
use crate::routes::query_support::{
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
resolve_effective_workspace_id,
};
use axum::extract::{Extension, Query, State};
use axum::http::StatusCode;
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use bridge_runtime::{
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::time::Duration;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentContentQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentMetaQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentSaveRequest {
pub document_id: String,
pub workspace_id: Option<String>,
pub revision: Option<u64>,
pub conflict_detection_key: Option<String>,
pub content: Value,
pub snapshot_captured_at: Option<String>,
pub block_count: Option<u64>,
}
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
(
StatusCode::OK,
Json(json!({
"ok": true,
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
"result": result,
})),
)
}
fn read_env_or_dotenv(key: &str) -> Option<String> {
if let Ok(value) = std::env::var(key) {
let trimmed = value.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.join(".env.all");
let content = fs::read_to_string(root).ok()?;
for line in content.lines() {
let line = line.trim_end_matches('\r');
if line.starts_with('#') || line.trim().is_empty() {
continue;
}
let Some((k, v)) = line.split_once('=') else {
continue;
};
if k.trim() != key {
continue;
}
let trimmed = v.trim().trim_matches('"').to_string();
if !trimmed.is_empty() {
return Some(trimmed);
}
}
None
}
fn next_documents_base_url() -> String {
read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV)
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
.trim()
.trim_end_matches('/')
.to_string()
}
fn should_proxy_via_next(context: &RequestContext) -> bool {
context
.auth
.cookie_header
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
fn build_next_proxy_headers(
context: &RequestContext,
effective_workspace_id: Option<&str>,
) -> reqwest::header::HeaderMap {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) {
let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else {
return;
};
let Ok(header_value) = HeaderValue::from_str(value) else {
return;
};
headers.insert(header_name, header_value);
}
let mut headers = HeaderMap::new();
if let Some(cookie) = context.auth.cookie_header.as_deref() {
insert(&mut headers, "cookie", cookie);
}
if let Some(authorization) = context.auth.authorization.as_deref() {
insert(&mut headers, "authorization", authorization);
}
insert(&mut headers, "x-request-id", &context.trace.request_id);
insert(&mut headers, "x-trace-id", &context.trace.trace_id);
insert(
&mut headers,
"x-mnote-source-channel",
&context.source.channel,
);
insert(
&mut headers,
"x-mnote-source-client",
&context.source.client,
);
insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id);
insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type);
if let Some(session_id) = context.auth.session_id.as_deref() {
insert(&mut headers, "x-mnote-session-id", session_id);
}
if let Some(workspace_id) = effective_workspace_id {
insert(&mut headers, "x-mnote-workspace-id", workspace_id);
}
headers
}
async fn send_next_documents_request(
context: &RequestContext,
request: reqwest::RequestBuilder,
phase: &'static str,
) -> Result<Value, WebError> {
let response = request.send().await.map_err(|error| {
let base = if error.is_timeout() {
WebError::gateway_timeout_code(
"next_proxy_timeout",
format!("Next compat 请求超时: {error}"),
)
} else {
WebError::service_unavailable_code(
"next_proxy_unavailable",
format!("Next compat 请求失败: {error}"),
)
};
base.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
})?;
let status = response.status();
let text = response.text().await.map_err(|error| {
WebError::bad_gateway_code(
"next_proxy_bad_response",
format!("Next compat 响应读取失败: {error}"),
)
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
let payload = serde_json::from_str::<Value>(&text).map_err(|_| {
let snippet: String = text.chars().take(180).collect();
WebError::bad_gateway_code(
"next_proxy_bad_response",
format!("Next compat 返回了非 JSON 内容: {snippet}"),
)
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string())
})?;
if !status.is_success() {
let message = payload
.get("error")
.and_then(Value::as_str)
.or_else(|| payload.get("message").and_then(Value::as_str))
.unwrap_or("Next compat 文档接口请求失败");
let web_error = match status {
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message)
}
reqwest::StatusCode::NOT_FOUND => {
WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message)
}
_ => WebError::bad_gateway_code("next_proxy_error", message),
};
return Err(web_error
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "next")
.with_header("x-upstream-status", status.as_u16().to_string()));
}
Ok(payload)
}
async fn proxy_next_documents_meta(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let mut url =
reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| {
WebError::internal(format!("Next compat meta URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_meta_url")
.with_header("x-upstream-service", "next")
})?;
url.query_pairs_mut().append_pair("documentId", document_id);
if let Some(workspace_id) = effective_workspace_id {
url.query_pairs_mut()
.append_pair("workspaceId", workspace_id);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_meta_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.get(url)
.headers(build_next_proxy_headers(context, effective_workspace_id)),
"next_proxy_meta",
)
.await?;
Ok(payload.get("doc").cloned().unwrap_or(Value::Null))
}
async fn proxy_next_documents_content(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let mut url =
reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| {
WebError::internal(format!("Next compat content URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_content_url")
.with_header("x-upstream-service", "next")
})?;
url.query_pairs_mut().append_pair("documentId", document_id);
if let Some(workspace_id) = effective_workspace_id {
url.query_pairs_mut()
.append_pair("workspaceId", workspace_id);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_content_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.get(url)
.headers(build_next_proxy_headers(context, effective_workspace_id)),
"next_proxy_content",
)
.await?;
Ok(json!({
"content": payload.get("content").cloned().unwrap_or(Value::Null),
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
"pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null),
}))
}
async fn proxy_next_documents_save(
context: &RequestContext,
effective_workspace_id: Option<&str>,
body: &DocumentSaveRequest,
) -> Result<Value, WebError> {
let base_url = next_documents_base_url();
let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| {
WebError::internal(format!("Next compat save URL 非法: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_save_url")
.with_header("x-upstream-service", "next")
})?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
.with_context(context)
.with_header("x-error-phase", "next_proxy_save_client")
.with_header("x-upstream-service", "next")
})?;
let payload = send_next_documents_request(
context,
client
.post(url)
.headers(build_next_proxy_headers(context, effective_workspace_id))
.json(&json!({
"documentId": body.document_id,
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"content": body.content,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
})),
"next_proxy_save",
)
.await?;
Ok(json!({
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
"conflictDetectionKey": payload
.get("conflictDetectionKey")
.cloned()
.unwrap_or(Value::Null),
"ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)),
}))
}
pub async fn meta(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentMetaQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = query.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_meta(&context, effective_workspace_id.as_deref(), document_id)
.await?;
return Ok(ok_response(&context, result));
}
let result = fetch_documents_meta_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
document_id,
)
.await?;
Ok(ok_response(&context, result))
}
pub async fn content(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentContentQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = query.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_content(&context, effective_workspace_id.as_deref(), document_id)
.await?;
return Ok(ok_response(&context, result));
}
let result = execute_runtime_query_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
RuntimeQueryEnvelopeWire {
name: "documents.content.get".into(),
payload: json!({
"documentId": document_id,
"workspaceId": effective_workspace_id,
}),
},
)
.await?;
Ok(ok_response(&context, result))
}
pub async fn save(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<DocumentSaveRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let document_id = body.document_id.trim();
if document_id.is_empty() {
return Err(
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
.with_context(&context),
);
}
let effective_workspace_id =
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
if should_proxy_via_next(&context) {
let result =
proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?;
return Ok(ok_response(&context, result));
}
let command = RuntimeCommandEnvelopeWire {
name: "documents.save".into(),
command_id: format!("document_save_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
},
target: Some(RuntimeTargetWire {
workspace_id: effective_workspace_id.clone(),
page_id: Some(document_id.to_string()),
block_id: None,
}),
payload: json!({
"documentId": document_id,
"workspaceId": effective_workspace_id,
"revision": body.revision,
"conflictDetectionKey": body.conflict_detection_key,
"content": body.content,
"snapshotCapturedAt": body.snapshot_captured_at,
"blockCount": body.block_count,
}),
reason: Some("mnote-web human editor save".into()),
refs: vec!["mnote-web-editor-runtime".into()],
dry_run: false,
validate_only: false,
};
let result = execute_runtime_command_via_convex(
state.config(),
&context,
effective_workspace_id.as_deref(),
command,
)
.await?;
Ok(ok_response(&context, result))
}
#[cfg(test)]
mod tests {
use crate::app::{build_app, AppConfig, AppState};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::Value;
use tower::util::ServiceExt;
fn app() -> axum::Router {
build_app(AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
bind_addr: "127.0.0.1:0".into(),
hermes_base_path: "/api/hermes".into(),
compat_next_base_path: "/api/compat/next".into(),
convex_url: None,
convex_admin_key: None,
allow_dev_fixtures: true,
query_fixtures_json: Some(
r#"{
"documents:getMeta": {
"id": "doc_1",
"workspace_id": "ws_demo",
"title": "服务端页面",
"updated_at": "2026-04-18T09:30:00Z",
"can_edit": true,
"disable_download": false,
"disable_copy": false,
"wide_layout": false,
"use_small_text": false,
"show_heading_numbers": true,
"show_toc": true,
"show_structure": true,
"protect_editing": false,
"show_word_count": true,
"collapse_backlinks": false,
"page_font": "default",
"layout_density": "normal",
"hide_child_pages": false,
"show_block_ref_count": true,
"embed_default_block_id": "heading_1",
"word_count": 42,
"character_count": 128,
"block_count": 3,
"todo_total": 1,
"todo_done": 0
},
"documents:getContent": {
"title": "服务端页面",
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一" }]
}
],
"revision": 7,
"conflict_detection_key": "doc_1:7"
}
}"#
.into(),
),
mutation_fixtures_json: Some(
r#"{
"documents:updateContent": {
"ok": true,
"updated_at": "2026-04-18T09:45:00Z",
"revision": 8,
"conflict_detection_key": "doc_1:8"
}
}"#
.into(),
),
dev_user_id: "dev-user".into(),
dev_user_name: "开发用户".into(),
dev_user_email: "dev@mnote.local".into(),
}))
}
#[tokio::test]
async fn documents_meta_route_returns_document_metadata() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/documents/meta?documentId=doc_1&workspaceId=ws_demo")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["id"], "doc_1");
assert_eq!(payload["result"]["workspace_id"], "ws_demo");
assert_eq!(payload["result"]["title"], "服务端页面");
assert_eq!(payload["result"]["show_structure"], true);
}
#[tokio::test]
async fn documents_content_route_returns_page_subtree() {
let response = app()
.oneshot(
Request::builder()
.uri("/api/documents/content?documentId=doc_1")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["revision"], 7);
assert_eq!(payload["result"]["pageSubtree"]["rootNodeId"], "doc_1");
assert_eq!(
payload["result"]["pageSubtree"]["outline"][0]["title"],
"章节一"
);
}
#[tokio::test]
async fn documents_save_route_executes_documents_save_command() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/documents/save")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"documentId": "doc_1",
"workspaceId": "ws_demo",
"revision": 7,
"conflictDetectionKey": "doc_1:7",
"content": [
{
"id": "heading_1",
"type": "heading",
"props": { "level": 1 },
"content": [{ "type": "text", "text": "章节一(已编辑)" }]
}
],
"blockCount": 1
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["revision"], 8);
assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
}
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,6 +1,8 @@
mod bridge; mod bridge;
mod command_support; mod command_support;
mod compat; mod compat;
mod documents;
mod editor;
mod health; mod health;
mod hermes; mod hermes;
mod kernel; mod kernel;
@@ -22,6 +24,15 @@ pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/health", get(health::health)) .route("/health", get(health::health))
.route("/tree", get(tree::tree_shell)) .route("/tree", get(tree::tree_shell))
.route("/document-debug", get(editor::document_editor_shell))
.route("/document", get(editor::document_editor_shell))
.route("/api/documents/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content))
.route("/api/documents/save", post(documents::save))
.route(
"/api/documents/runtime/transform",
post(editor::transform_runtime_snapshot),
)
.route( .route(
"/api/tree/projections/sidebar", "/api/tree/projections/sidebar",
get(kernel::project_tree_sidebar), get(kernel::project_tree_sidebar),
@@ -7,7 +7,9 @@ use bridge_runtime::{
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan, RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
RuntimeSourceWire, RuntimeSourceWire,
}; };
use core_protocol::{GetPageMeta, QueryEnvelope};
use serde_json::Value; use serde_json::Value;
use storage_convex_bridge::{build_query_request, BridgeContext};
pub fn resolve_effective_workspace_id( pub fn resolve_effective_workspace_id(
context: &RequestContext, context: &RequestContext,
@@ -77,6 +79,31 @@ pub fn runtime_context(
} }
} }
fn storage_context(
context: &RequestContext,
effective_workspace_id: Option<&str>,
) -> BridgeContext {
BridgeContext {
deployment_id: context.workspace.deployment_id.clone(),
project_id: context.workspace.project_id.clone(),
request_id: context.trace.request_id.clone(),
trace_id: context.trace.trace_id.clone(),
actor_type: context.auth.actor_type.clone(),
actor_id: context.auth.actor_id.clone(),
session_id: context.auth.session_id.clone(),
workspace_id: effective_workspace_id
.map(ToOwned::to_owned)
.or_else(|| context.workspace.workspace_id.clone()),
tenant_id: context.workspace.tenant_id.clone(),
auth_token: context.auth.authorization.clone(),
source_channel: context.source.channel.clone(),
source_client: context.source.client.clone(),
idempotency_key: context.source.idempotency_key.clone(),
validate_only: false,
dry_run: false,
}
}
pub fn build_runtime_query_plan( pub fn build_runtime_query_plan(
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
@@ -96,6 +123,34 @@ pub fn build_runtime_query_plan(
Ok(plan) Ok(plan)
} }
pub fn build_documents_meta_query_plan(
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<RuntimeQueryExecutionPlan, WebError> {
let query = QueryEnvelope {
name: "documents.meta.get".into(),
payload: GetPageMeta {
page_id: document_id.to_string(),
workspace_id: effective_workspace_id.map(ToOwned::to_owned),
},
};
let request = build_query_request(&storage_context(context, effective_workspace_id), &query)
.map_err(|error| WebError::bad_request(error.message).with_context(context))?;
Ok(RuntimeQueryExecutionPlan {
query_name: query.name,
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
actor_id: request.actor_id,
payload_json: request.payload_json,
args_json: serde_json::json!({
"id": document_id,
}),
})
}
pub async fn fetch_query_data_via_convex( pub async fn fetch_query_data_via_convex(
config: &AppConfig, config: &AppConfig,
context: &RequestContext, context: &RequestContext,
@@ -106,6 +161,16 @@ pub async fn fetch_query_data_via_convex(
execute_convex_query_plan(config, context, &plan).await execute_convex_query_plan(config, context, &plan).await
} }
pub async fn fetch_documents_meta_via_convex(
config: &AppConfig,
context: &RequestContext,
effective_workspace_id: Option<&str>,
document_id: &str,
) -> Result<Value, WebError> {
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?;
execute_convex_query_plan(config, context, &plan).await
}
pub fn execute_runtime_query_against_data( pub fn execute_runtime_query_against_data(
context: &RequestContext, context: &RequestContext,
effective_workspace_id: Option<&str>, effective_workspace_id: Option<&str>,
+6 -5
View File
@@ -261,7 +261,8 @@ mod tests {
page_id: "page_2".into(), page_id: "page_2".into(),
workspace_id: Some("ws_1".into()), workspace_id: Some("ws_1".into()),
revision: Some(7), revision: Some(7),
content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}".into(), content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}"
.into(),
conflict_detection_key: Some("rev:7".into()), conflict_detection_key: Some("rev:7".into()),
}, },
reason: Some("嵌入页面".into()), reason: Some("嵌入页面".into()),
@@ -274,7 +275,9 @@ mod tests {
build_write_request(&demo_context(), &command).expect("write request should build"); build_write_request(&demo_context(), &command).expect("write request should build");
assert_eq!(request.function_name, "documents:updateContent"); assert_eq!(request.function_name, "documents:updateContent");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request.payload_json.contains("\"name\":\"documents.embed\"")); assert!(request
.payload_json
.contains("\"name\":\"documents.embed\""));
} }
#[test] #[test]
@@ -769,9 +772,7 @@ mod tests {
build_query_request(&demo_context(), &query).expect("query request should build"); build_query_request(&demo_context(), &query).expect("query request should build");
assert_eq!(request.function_name, "search:recent"); assert_eq!(request.function_name, "search:recent");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1")); assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request assert!(request.payload_json.contains("\"name\":\"search.recent\""));
.payload_json
.contains("\"name\":\"search.recent\""));
} }
#[test] #[test]
@@ -28,6 +28,34 @@ impl BridgeError {
message: message.into(), message: message.into(),
} }
} }
pub fn unauthorized(message: impl Into<String>) -> Self {
Self {
kind: BridgeErrorKind::Unauthorized,
message: message.into(),
}
}
pub fn conflict(message: impl Into<String>) -> Self {
Self {
kind: BridgeErrorKind::Conflict,
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
kind: BridgeErrorKind::NotFound,
message: message.into(),
}
}
pub fn rejected(message: impl Into<String>) -> Self {
Self {
kind: BridgeErrorKind::Rejected,
message: message.into(),
}
}
} }
pub type BridgeResult<T> = Result<T, BridgeError>; pub type BridgeResult<T> = Result<T, BridgeError>;
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.89.0"
targets = ["wasm32-unknown-unknown"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
[package]
name = "mnote-leptos-tiptap-spike"
version = "0.1.0"
edition = "2021"
[workspace]
[dependencies]
console_error_panic_hook = "0.1.7"
leptos = { version = "0.8.19", features = ["csr"] }
leptos_dom = "0.8.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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"
default-features = false
features = ["component", "full"]
@@ -0,0 +1,7 @@
[build]
target = "index.html"
dist = "dist"
public_url = "/"
[watch]
ignore = ["dist"]
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mnote leptos-tiptap spike</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe6;
--panel: rgba(255, 252, 246, 0.94);
--panel-strong: #fffdfa;
--ink: #1f2937;
--muted: #5b6473;
--line: rgba(100, 116, 139, 0.24);
--accent: #0f766e;
--accent-strong: #0b5f59;
--accent-soft: rgba(15, 118, 110, 0.12);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 26%),
linear-gradient(180deg, #fcf8f2 0%, var(--bg) 100%);
color: var(--ink);
font-family: "Noto Sans CJK SC", "Source Han Sans SC", "PingFang SC", sans-serif;
}
button,
input,
textarea {
font: inherit;
}
</style>
</head>
<body></body>
</html>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
{"rustc_fingerprint":4704921907237318175,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.88.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.88.0 (6b00bc388 2025-06-23)\nbinary: rustc\ncommit-hash: 6b00bc3880198600130e1cf62b8f8a93494488cc\ncommit-date: 2025-06-23\nhost: x86_64-unknown-linux-gnu\nrelease: 1.88.0\nLLVM version: 20.1.5\n","stderr":""}},"successes":{}} {"rustc_fingerprint":9228011546279038255,"outputs":{"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/lix/.rustup/toolchains/1.89.0-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""},"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.89.0 (29483883e 2025-08-04)\nbinary: rustc\ncommit-hash: 29483883eed69d5fb4db01964cdf2af4d86e9cb2\ncommit-date: 2025-08-04\nhost: x86_64-unknown-linux-gnu\nrelease: 1.89.0\nLLVM version: 20.1.7\n","stderr":""}},"successes":{}}
@@ -0,0 +1,113 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
openDocument,
prepareTempTreeFixture,
} = require("./tree-shell-smoke-helpers");
async function runDefaultShellPath(page, fixture) {
await openDocument(page, fixture.workspaceId, fixture.parentId);
await page.getByRole("button", { name: "进入编辑" }).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
(await page.locator(`iframe[title="mnote-web-document-shell-${fixture.parentId}"]`).count()) === 0,
"默认文档页不应继续挂载 mnote-web 文档壳 iframe",
);
await page.getByRole("button", { name: "进入编辑" }).click({ timeout: UI_TIMEOUT_MS });
await page.getByLabel("页面标题").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator(".wolai-editor").first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return {
compatReady: true,
};
}
async function runRuntimeDebugPath(page, fixture) {
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task103-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("#block-editor-list").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-slash").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return {
debugUrl: page.url(),
};
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let caughtError = null;
let fixture = null;
try {
const viewer = await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
const primary = await runDefaultShellPath(page, fixture);
const debug = await runRuntimeDebugPath(page, fixture);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
viewerUserId: viewer.userId,
workspaceId: fixture.workspaceId,
parentId: fixture.parentId,
childId: fixture.childId,
primary,
debug,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
} else {
console.error(
`清理临时页面失败:${
cleanupError instanceof Error
? cleanupError.stack || cleanupError.message
: String(cleanupError)
}`,
);
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,100 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task104-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const textarea = page.locator("textarea[data-block-input-id]").first();
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await textarea.click({ timeout: UI_TIMEOUT_MS });
await textarea.fill("你好 runtime input", { timeout: UI_TIMEOUT_MS });
await page.locator("#editor-save-status").getByText("saved").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const value = await textarea.inputValue();
assert(value === "你好 runtime input", `输入值不符合预期:${value}`);
const selection = await page.locator("#editor-selection-id").textContent();
assert(selection && selection.includes("_block_1"), `selection 未更新到真实输入块:${selection}`);
const eventLog = (await page.locator("#editor-event-log").textContent()) || "";
assert(
eventLog.includes("human_editor_input.beforeinput"),
`未记录 beforeinput runtime 标记:${eventLog}`,
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
selection,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,121 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task105-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const firstTextarea = page.locator("textarea[data-block-input-id]").first();
await firstTextarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await firstTextarea.fill("AlphaBeta", { timeout: UI_TIMEOUT_MS });
await firstTextarea.evaluate((node) => {
node.setSelectionRange(5, 5);
});
await firstTextarea.press("Enter", { timeout: UI_TIMEOUT_MS });
const textareas = page.locator("textarea[data-block-input-id]");
await textareas.nth(1).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert((await textareas.count()) === 2, "Enter 拆块后未出现第二个输入块");
assert((await textareas.nth(0).inputValue()) === "Alpha", "拆块后首块文本不正确");
assert((await textareas.nth(1).inputValue()) === "Beta", "拆块后次块文本不正确");
await textareas.nth(1).press("Tab", { timeout: UI_TIMEOUT_MS });
await page.locator(".editor-row-meta").nth(1).getByText("depth=1").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await textareas.nth(1).press("Shift+Tab", { timeout: UI_TIMEOUT_MS });
await page.locator(".editor-row-meta").nth(1).getByText("depth=0").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await textareas.nth(1).evaluate((node) => {
node.setSelectionRange(0, 0);
});
await textareas.nth(1).press("Backspace", { timeout: UI_TIMEOUT_MS });
assert((await page.locator("textarea[data-block-input-id]").count()) === 1, "合并后块数量未回到 1");
assert(
(await page.locator("textarea[data-block-input-id]").first().inputValue()) === "AlphaBeta",
"Backspace 合并后文本不正确",
);
const saveStatus = ((await page.locator("#editor-save-status").textContent()) || "").trim().toLowerCase();
assert(
saveStatus === "saved" || saveStatus === "saving" || saveStatus === "idle",
`结构事务后保存状态异常:${saveStatus}`,
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
saveStatus,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,101 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task106-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const textarea = page.locator("textarea[data-block-input-id]").first();
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await textarea.fill("Command", { timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-slash").click({ timeout: UI_TIMEOUT_MS });
await page.locator('[data-slash-action="heading"]').click({ timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-page-ref").click({ timeout: UI_TIMEOUT_MS });
await page.locator("#editor-command-block-ref").click({ timeout: UI_TIMEOUT_MS });
await page.locator("#editor-save-status").getByText("saved").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
const value = await textarea.inputValue();
assert(
value.includes("[[page]]") && value.includes("((block))"),
`引用 token 未写入真实输入器:${value}`,
);
const title = (await page.locator(".editor-row-title").first().textContent()) || "";
assert(title.includes("Heading"), `slash 切块后未变成 heading${title}`);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
value,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
@@ -0,0 +1,107 @@
"use strict";
const { chromium } = require("playwright");
const {
BASE_URL,
MNOTE_WEB_BASE_URL,
UI_TIMEOUT_MS,
assert,
cleanupDocuments,
ensureAuthenticated,
prepareTempTreeFixture,
openDocument,
} = require("./tree-shell-smoke-helpers");
async function openRuntimeDebug(page, fixture) {
await page.goto(`${BASE_URL}/api/auth/mnote-web-token`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
await page.goto(
`${MNOTE_WEB_BASE_URL}/document-debug?documentId=${fixture.parentId}&workspaceId=${fixture.workspaceId}&host=task107-smoke`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByText("Document Runtime Debug").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}
async function main() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 960 },
});
const page = await context.newPage();
let fixture = null;
let caughtError = null;
try {
await ensureAuthenticated(page, context.request);
fixture = await prepareTempTreeFixture(context.request);
await openDocument(page, fixture.workspaceId, fixture.parentId);
await openRuntimeDebug(page, fixture);
const textarea = page.locator("textarea[data-block-input-id]").first();
await textarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await textarea.fill("persist runtime content", { timeout: UI_TIMEOUT_MS });
await page.locator("#editor-save-status").getByText("saved").waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
await page.reload({ waitUntil: "commit", timeout: UI_TIMEOUT_MS });
const reloadedTextarea = page.locator("textarea[data-block-input-id]").first();
await reloadedTextarea.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
assert(
(await reloadedTextarea.inputValue()) === "persist runtime content",
"刷新后未回放最近一次保存内容",
);
await page.goto(
`${BASE_URL}/documents/${fixture.parentId}?workspaceId=${encodeURIComponent(fixture.workspaceId)}`,
{ waitUntil: "commit", timeout: UI_TIMEOUT_MS },
);
await page.getByRole("button", { name: "进入编辑" }).waitFor({
state: "visible",
timeout: UI_TIMEOUT_MS,
});
assert(
(await page.locator(`iframe[title="mnote-web-document-shell-${fixture.parentId}"]`).count()) === 0,
"默认文档页不应继续挂载 runtime debug iframe",
);
console.log(
JSON.stringify(
{
ok: true,
baseUrl: BASE_URL,
workspaceId: fixture.workspaceId,
documentId: fixture.parentId,
},
null,
2,
),
);
} catch (error) {
caughtError = error;
} finally {
if (fixture) {
try {
await cleanupDocuments(context.request, fixture.createdIds);
} catch (cleanupError) {
if (!caughtError) {
caughtError = cleanupError;
}
}
}
await page.close().catch(() => undefined);
await context.close().catch(() => undefined);
await browser.close().catch(() => undefined);
}
if (caughtError) {
throw caughtError;
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
+90 -7
View File
@@ -5,6 +5,7 @@ const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录"; const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
const TEST_USERNAME_PREFIX = "测试用户";
function assert(condition, message) { function assert(condition, message) {
if (!condition) { if (!condition) {
@@ -92,19 +93,101 @@ async function getViewerIdentity(requestContext) {
return payload; return payload;
} }
async function waitForAuthenticatedRedirect(page, timeout = 8_000) {
await page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout,
waitUntil: "commit",
});
}
async function isVisible(locator) {
try {
return await locator.isVisible();
} catch {
return false;
}
}
async function completeUsernameSetupIfNeeded(page) {
const saveButton = page.getByRole("button", { name: "保存并继续" });
if (!(await isVisible(saveButton))) {
return false;
}
const usernameInput = page.locator('input[name="username"]').last();
await usernameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const currentValue = ((await usernameInput.inputValue().catch(() => "")) || "").trim();
const username = currentValue || `${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`;
await usernameInput.fill(username, { timeout: UI_TIMEOUT_MS });
await saveButton.click({ timeout: UI_TIMEOUT_MS });
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
return true;
}
async function registerTestAccountIfNeeded(page) {
const switchButton = page.getByRole("button", { name: "还没有账户?立即注册" });
if (!(await isVisible(switchButton))) {
return false;
}
await switchButton.click({ timeout: UI_TIMEOUT_MS });
await page.locator('input[name="email"]').fill("test@example.com", { timeout: UI_TIMEOUT_MS });
await page.locator('input[name="username"]').fill(`${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`, {
timeout: UI_TIMEOUT_MS,
});
await page.locator('input[name="password"]').fill("Test123456", { timeout: UI_TIMEOUT_MS });
await page.getByRole("button", { name: "注册" }).click({ timeout: UI_TIMEOUT_MS });
try {
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
return true;
} catch {
return completeUsernameSetupIfNeeded(page);
}
}
async function waitForViewerIdentity(requestContext, attempts = 6) {
let lastError = null;
for (let index = 0; index < attempts; index += 1) {
try {
return await getViewerIdentity(requestContext);
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
throw lastError instanceof Error ? lastError : new Error("获取当前用户失败");
}
async function ensureAuthenticated(page, requestContext) { async function ensureAuthenticated(page, requestContext) {
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
if (page.url().includes("/auth")) { if (page.url().includes("/auth")) {
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME }); const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await Promise.race([
page.waitForURL((url) => !url.toString().includes("/auth"), {
timeout: UI_TIMEOUT_MS,
waitUntil: "commit",
}),
quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }),
]);
if (!page.url().includes("/auth")) {
return await waitForViewerIdentity(requestContext);
}
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS }); await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
await page.waitForURL((url) => !url.toString().includes("/auth"), { try {
timeout: UI_TIMEOUT_MS, await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
}); } catch {
const completedUsername = await completeUsernameSetupIfNeeded(page);
if (!completedUsername) {
const registered = await registerTestAccountIfNeeded(page);
if (!registered) {
throw new Error("测试账号登录后仍停留在 /auth,且未进入可恢复的用户名/注册流程");
}
}
}
} }
return await getViewerIdentity(requestContext); return await waitForViewerIdentity(requestContext);
} }
async function prepareTempTreeFixture(requestContext) { async function prepareTempTreeFixture(requestContext) {
@@ -137,7 +220,7 @@ async function cleanupDocuments(requestContext, createdIds) {
async function openDocument(page, workspaceId, documentId) { async function openDocument(page, workspaceId, documentId) {
const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`; const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await page.goto(url, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
return url; return url;
} }
@@ -6,7 +6,8 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server"; import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge"; import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
import { buildPageSubtreeProjection } from "@/lib/documents/page-subtree"; 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 { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
interface DocumentPageProps { interface DocumentPageProps {
@@ -48,15 +49,18 @@ type DocumentContentPayload = {
content?: unknown; content?: unknown;
revision?: number | null; revision?: number | null;
conflict_detection_key?: string | null; conflict_detection_key?: string | null;
page_subtree?: PageSubtreeProjection | null;
}; };
async function fetchDocumentContentOnServer(input: { async function fetchDocumentContentOnServer(input: {
documentId: string; documentId: string;
workspaceId: string; workspaceId: string;
title?: string | null;
}): Promise<{ }): Promise<{
content: unknown; content: unknown;
revision: number | null; revision: number | null;
conflictDetectionKey: string | null; conflictDetectionKey: string | null;
pageSubtree: PageSubtreeProjection | null;
}> { }> {
try { try {
const headerList = await headers(); const headerList = await headers();
@@ -101,23 +105,19 @@ async function fetchDocumentContentOnServer(input: {
client, client,
plan, plan,
}); });
const normalized = normalizeDocumentContentResponse({
documentId: input.documentId,
title: input.title ?? null,
payload: result,
});
return { return normalized;
content: result?.content ?? null,
revision:
typeof result?.revision === "number" && Number.isInteger(result.revision)
? result.revision
: 0,
conflictDetectionKey:
typeof result?.conflict_detection_key === "string" && result.conflict_detection_key.trim()
? result.conflict_detection_key
: `${input.documentId}:0`,
};
} catch { } catch {
return { return {
content: null, content: null,
revision: null, revision: null,
conflictDetectionKey: null, conflictDetectionKey: null,
pageSubtree: null,
}; };
} }
} }
@@ -169,11 +169,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
const initialDocumentContent = await fetchDocumentContentOnServer({ const initialDocumentContent = await fetchDocumentContentOnServer({
documentId: doc.id, documentId: doc.id,
workspaceId: doc.workspace_id, workspaceId: doc.workspace_id,
});
const initialPageSubtree = buildPageSubtreeProjection({
documentId: doc.id,
title: doc.title ?? "无标题", title: doc.title ?? "无标题",
content: initialDocumentContent.content,
}); });
return ( return (
@@ -187,7 +183,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
initialContent={initialDocumentContent.content} initialContent={initialDocumentContent.content}
initialContentRevision={initialDocumentContent.revision} initialContentRevision={initialDocumentContent.revision}
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey} initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
initialPageSubtree={initialPageSubtree} initialPageSubtree={initialDocumentContent.pageSubtree}
initialOptions={initialOptions} initialOptions={initialOptions}
initialStats={initialStats} initialStats={initialStats}
openTableId={openTableId} openTableId={openTableId}
@@ -7,6 +7,8 @@ import {
buildDocumentQueryEnvelope, buildDocumentQueryEnvelope,
documentBridgeErrorResponse, documentBridgeErrorResponse,
} from "@/lib/documents/bridge"; } from "@/lib/documents/bridge";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import { import {
executeRustBridgeQueryTransport, executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan, resolveRustBridgeQueryPlan,
@@ -35,6 +37,8 @@ export async function GET(request: Request) {
content?: unknown; content?: unknown;
revision?: number | null; revision?: number | null;
conflict_detection_key?: string | null; conflict_detection_key?: string | null;
title?: string | null;
page_subtree?: PageSubtreeProjection | null;
} | null>({ } | null>({
client, client,
plan, plan,
@@ -54,16 +58,17 @@ export async function GET(request: Request) {
); );
} }
const normalized = normalizeDocumentContentResponse({
documentId,
title: typeof result.title === "string" && result.title.trim() ? result.title : null,
payload: result,
});
return NextResponse.json({ return NextResponse.json({
content: result.content ?? null, content: normalized.content,
revision: revision: normalized.revision,
typeof result.revision === "number" && Number.isInteger(result.revision) conflictDetectionKey: normalized.conflictDetectionKey,
? result.revision pageSubtree: normalized.pageSubtree,
: 0,
conflictDetectionKey:
typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim()
? result.conflict_detection_key
: `${documentId}:0`,
meta: { meta: {
requestId: bridgeContext.requestId, requestId: bridgeContext.requestId,
traceId: bridgeContext.traceId, traceId: bridgeContext.traceId,
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DocumentToc } from "@/components/editor/document-toc"; import { DocumentToc } from "@/components/editor/document-toc";
import { DocumentReadView } from "@/components/editor/document-read-view"; import { DocumentReadView } from "@/components/editor/document-read-view";
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree"; import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import { import {
deleteDocumentCommand, deleteDocumentCommand,
embedDocumentCommand, embedDocumentCommand,
@@ -143,6 +143,11 @@ export function DocumentContent({
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker); const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题"); const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const [content, setContent] = useState<unknown>(initialContent); const [content, setContent] = useState<unknown>(initialContent);
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
initialPageSubtree,
);
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(title ?? "无标题");
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision); const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey); const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const [contentLoading, setContentLoading] = useState(() => initialContent == null); const [contentLoading, setContentLoading] = useState(() => initialContent == null);
@@ -259,6 +264,14 @@ export function DocumentContent({
setPageTitle(title ?? "无标题"); setPageTitle(title ?? "无标题");
}, [title]); }, [title]);
useEffect(() => {
setServerPageSubtreeTitle(title ?? "无标题");
}, [title]);
useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree);
}, [initialPageSubtree]);
useEffect(() => { useEffect(() => {
setOptions(initialOptions ?? defaultOptions); setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]); }, [initialOptions]);
@@ -275,6 +288,10 @@ export function DocumentContent({
setConflictDetectionKey(initialConflictDetectionKey); setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]); }, [initialConflictDetectionKey]);
useEffect(() => {
setServerContentSnapshot(initialContent);
}, [initialContent]);
useEffect(() => { useEffect(() => {
const nextBlocks = extractPageBlocks(content); const nextBlocks = extractPageBlocks(content);
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null; latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
@@ -354,9 +371,11 @@ export function DocumentContent({
content?: unknown; content?: unknown;
revision?: number | null; revision?: number | null;
conflictDetectionKey?: string | null; conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
}; };
if (canceled) return; if (canceled) return;
setContent(payload.content ?? null); setContent(payload.content ?? null);
setServerContentSnapshot(payload.content ?? null);
setContentRevision( setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision) typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision ? payload.revision
@@ -367,6 +386,13 @@ export function DocumentContent({
? payload.conflictDetectionKey ? payload.conflictDetectionKey
: `${documentId}:0`, : `${documentId}:0`,
); );
setServerPageSubtreeSnapshot(payload.pageSubtree ?? null);
setServerPageSubtreeTitle(
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title
: title ?? "无标题",
);
} catch (error) { } catch (error) {
if (canceled) return; if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return; if ((error as { name?: string })?.name === "AbortError") return;
@@ -393,7 +419,7 @@ export function DocumentContent({
contentLoadingTimerRef.current = null; contentLoadingTimerRef.current = null;
} }
}; };
}, [documentId, initialContent, contentReloadKey, workspaceId]); }, [documentId, initialContent, contentReloadKey, title, workspaceId]);
const persistTitle = useCallback( const persistTitle = useCallback(
async (nextTitle: string) => { async (nextTitle: string) => {
@@ -730,18 +756,24 @@ export function DocumentContent({
options.hideChildPages && "wolai-hide-child-pages", options.hideChildPages && "wolai-hide-child-pages",
); );
const pageSubtree = useMemo(() => { const pageSubtree = useMemo(() => {
if (initialPageSubtree && content === initialContent && pageTitle === (title ?? "无标题")) { const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
return initialPageSubtree; const titleUnchanged = pageTitle === serverPageSubtreeTitle;
const contentUnchanged = content === serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return serverPageSubtreeSnapshot;
} }
return buildPageSubtreeProjection({ return null;
documentId, }, [
title: pageTitle, content,
content, pageTitle,
}); serverContentSnapshot,
}, [content, documentId, initialContent, initialPageSubtree, pageTitle, title]); serverPageSubtreeSnapshot,
serverPageSubtreeTitle,
]);
const readViewTocEntries = useMemo( const readViewTocEntries = useMemo(
() => () =>
pageSubtree.outline (pageSubtree?.outline ?? [])
.filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim()) .filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim())
.map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({ .map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({
id: anchorBlockId as string, id: anchorBlockId as string,
@@ -5,7 +5,6 @@ import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TocEntry } from "@/components/editor/document-toc"; import type { TocEntry } from "@/components/editor/document-toc";
import { import {
buildPageSubtreeProjection,
clampHeadingLevel, clampHeadingLevel,
extractPageBlocks, extractPageBlocks,
getInlineText, getInlineText,
@@ -181,11 +180,26 @@ const buildHeadingNumberingMapFromOutline = (outline: PageOutlineEntry[]): Map<s
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content); export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => { export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
return buildPageSubtreeProjection({ const counters = [0, 0, 0, 0, 0];
documentId: "preview", return blocks.flatMap((block) => {
title: "预览", if (block.type !== "heading") {
content: blocks, return [];
}).outline.map(({ id, level, numbering, title }) => ({ }
const level = clampHeadingLevel(block.props?.level);
counters[level - 1] += 1;
for (let index = level; index < counters.length; index += 1) {
counters[index] = 0;
}
return [{
id: String(block.id ?? `preview-heading-${counters.join("-")}`),
level,
numbering: counters
.slice(0, level)
.filter((value) => value > 0)
.join("."),
title: getInlineText(block.content) || "未命名标题",
}];
}).map(({ id, level, numbering, title }) => ({
id, id,
level, level,
numbering, numbering,
@@ -583,22 +597,15 @@ function DocumentReadStructurePanel({ pageSubtree }: { pageSubtree: PageSubtreeP
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) { export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
const blocks = extractReadViewBlocks(content); const blocks = extractReadViewBlocks(content);
const resolvedPageSubtree =
pageSubtree ??
buildPageSubtreeProjection({
documentId,
title: null,
content,
});
const headingNumberingById = const headingNumberingById =
resolvedPageSubtree.outline.length > 0 (pageSubtree?.outline.length ?? 0) > 0
? buildHeadingNumberingMapFromOutline(resolvedPageSubtree.outline) ? buildHeadingNumberingMapFromOutline(pageSubtree?.outline ?? [])
: buildHeadingNumberingMap(blocks); : buildHeadingNumberingMap(blocks);
if (blocks.length === 0) { if (blocks.length === 0) {
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null} {options.showStructure && pageSubtree ? <DocumentReadStructurePanel pageSubtree={pageSubtree} /> : null}
<div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]"> <div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]">
</div> </div>
@@ -608,7 +615,7 @@ export function DocumentReadView({ content, documentId, options, pageSubtree, cl
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null} {options.showStructure && pageSubtree ? <DocumentReadStructurePanel pageSubtree={pageSubtree} /> : null}
{renderBlocks(blocks, options, documentId, headingNumberingById, 0)} {renderBlocks(blocks, options, documentId, headingNumberingById, 0)}
</div> </div>
); );
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
describe("page subtree response helpers", () => {
it("保留服务端返回的 pageSubtree", () => {
const serverPageSubtree = {
projectionId: "server_projection",
projection: "page_tree",
rootNodeId: "doc_1",
rootNode: {
id: "doc_1",
parentNodeId: null,
nodeType: "page",
blockId: null,
anchorBlockId: null,
depth: 0,
metadata: {
title: "服务端标题",
textSnippet: null,
blockType: null,
headingLevel: null,
numbering: null,
childCount: 0,
order: 0,
path: ["doc_1"],
},
},
subtree: {
rootNodeId: "doc_1",
nodes: [],
},
outline: [],
evidence: [],
stats: {
blockCount: 0,
headingCount: 0,
evidenceCount: 0,
maxDepth: 0,
},
} satisfies PageSubtreeProjection;
const normalized = normalizeDocumentContentResponse({
documentId: "doc_1",
payload: {
content: null,
revision: 1,
conflictDetectionKey: "doc_1:1",
pageSubtree: serverPageSubtree,
},
});
expect(normalized.pageSubtree).toBe(serverPageSubtree);
});
it("在服务端缺失 pageSubtree 时保留空值", () => {
const normalized = normalizeDocumentContentResponse({
documentId: "doc_1",
title: "测试页面",
payload: {
content: [
{
id: "heading_1",
type: "heading",
props: { level: 1 },
content: [{ type: "text", text: "章节一" }],
},
],
revision: 3,
conflictDetectionKey: "doc_1:3",
},
});
expect(normalized.revision).toBe(3);
expect(normalized.conflictDetectionKey).toBe("doc_1:3");
expect(normalized.pageSubtree).toBeNull();
});
});
@@ -0,0 +1,48 @@
import {
type PageSubtreeProjection,
} from "@/lib/documents/page-subtree";
export type DocumentContentResponseLike = {
content?: unknown;
revision?: number | null;
conflict_detection_key?: string | null;
conflictDetectionKey?: string | null;
page_subtree?: PageSubtreeProjection | null;
pageSubtree?: PageSubtreeProjection | null;
title?: string | null;
};
export type NormalizedDocumentContentResponse = {
content: unknown;
revision: number;
conflictDetectionKey: string;
pageSubtree: PageSubtreeProjection | null;
};
export function normalizeDocumentContentResponse(input: {
documentId: string;
title?: string | null;
payload?: DocumentContentResponseLike | null;
}): NormalizedDocumentContentResponse {
const payload = input.payload ?? null;
const content = payload?.content ?? null;
const revision =
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0;
const conflictDetectionKey = (
typeof payload?.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: typeof payload?.conflict_detection_key === "string" && payload.conflict_detection_key.trim()
? payload.conflict_detection_key
: `${input.documentId}:0`
);
const pageSubtree = payload?.pageSubtree ?? payload?.page_subtree ?? null;
return {
content,
revision,
conflictDetectionKey,
pageSubtree,
};
}
+52 -10
View File
@@ -73,6 +73,36 @@ const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
return undefined; return undefined;
}; };
function getServerNodeBuiltin<T>(moduleName: string): T | null {
if (typeof window !== "undefined") {
return null;
}
const processWithBuiltin = process as NodeJS.Process & {
getBuiltinModule?: (id: string) => unknown;
};
if (typeof processWithBuiltin.getBuiltinModule === "function") {
const builtIn = processWithBuiltin.getBuiltinModule(moduleName);
if (builtIn) {
return builtIn as T;
}
}
try {
// 说明:某些 Node/打包环境仍保留 CommonJS require,这里只作为兜底。
const runtimeRequire = new Function(
'return typeof require === "function" ? require : null',
)() as NodeJS.Require | null;
if (runtimeRequire) {
return runtimeRequire(moduleName) as T;
}
} catch {
return null;
}
return null;
}
const readFromEnv = (): MnoteRuntimeConfig => ({ const readFromEnv = (): MnoteRuntimeConfig => ({
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1", useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL, supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
@@ -111,12 +141,14 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。 // 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
// 网页端运行时 process.cwd() 通常为 wolai-frontend。 // 网页端运行时 process.cwd() 通常为 wolai-frontend。
// 说明:这里不能直接写 `require("fs")` / `require("path")` // 说明:这里不能直接静态引入 `node:fs` / `node:path`
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖,触发持续重编译或空白页 // 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。
// 仅在服务端运行时通过惰性 require 读取本地 public/mnote-env.json // Node 22 优先走 `process.getBuiltinModule`,其余环境再兜底到 runtime require
const runtimeRequire = new Function("return require")() as NodeJS.Require; const fs = getServerNodeBuiltin<typeof import("fs")>("node:fs");
const fs = runtimeRequire("node:fs") as typeof import("fs"); const path = getServerNodeBuiltin<typeof import("path")>("node:path");
const path = runtimeRequire("node:path") as typeof import("path"); if (!fs || !path) {
return {};
}
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)` // 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。 // 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
@@ -224,18 +256,28 @@ export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv()); return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
} }
const isDesktop = process.env.MNOTE_DESKTOP === "1"; const isDesktop = process.env.MNOTE_DESKTOP === "1";
const publicRuntime = readFromPublicJson();
const envRuntime = readFromEnv();
// 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。 // 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
// Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被 // Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被
// public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。 // public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
const merged: MnoteRuntimeConfig = isDesktop const merged: MnoteRuntimeConfig = isDesktop
? { ? {
...readFromEnv(), ...envRuntime,
...readFromPublicJson(), ...publicRuntime,
isDesktop, isDesktop,
} }
: { : {
...readFromPublicJson(), ...publicRuntime,
...readFromEnv(), ...envRuntime,
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
...(publicRuntime.mnoteWebBaseUrl !== undefined
? { mnoteWebBaseUrl: publicRuntime.mnoteWebBaseUrl }
: {}),
...(publicRuntime.mnoteWebTreeShellEnabled !== undefined
? { mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled }
: {}),
isDesktop, isDesktop,
}; };
return normalizeRuntimeConfig(merged); return normalizeRuntimeConfig(merged);