Fix tiptap selection sync and toolbar event loop
This commit is contained in:
@@ -53,6 +53,7 @@ design
|
||||
|
||||
# Rust 本地构建与调试产物
|
||||
/rust/target/
|
||||
/rust/spikes/**/target/
|
||||
|
||||
# Harness 本地运行状态/调试产物
|
||||
/.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]: You’re 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]: You’re 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]: You’re 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]: You’re 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]: You’re 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]: You’re 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]: You’re 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]: 展开 / 收起
|
||||
@@ -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] STATS tasks_total=41 completed=41 failed=0 pending=0 blocked=0 attempts_total=32 checkpoints=38
|
||||
[2026-04-18T01:37:10Z] [SESSION-5] LOCK released
|
||||
[2026-04-18T01:48:47Z] [SESSION-6] LOCK acquired (pid=codex)
|
||||
[2026-04-18T01:48:47Z] [SESSION-6] INIT Harness resumed for Kernel Phase 7 document read-view/pageSubtree batch task-042..task-044
|
||||
[2026-04-18T01:48:47Z] [SESSION-6] INIT Environment health check: PASS
|
||||
[2026-04-18T01:48:47Z] [SESSION-6] Starting [task-042] 执行 Kernel Phase 7-1:把文档页 pageSubtree 固化为服务端首包与 /api/documents/content 稳定响应契约,减少前端读链即时拼装 (base=f9a45e89)
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-042] step=1/1 "已把 pageSubtree 固化为 SSR 首包与 /api/documents/content 响应契约,并补齐共享归一化 helper 与单测。"
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-042] (commit skipped by repo rule; base=f9a45e89)
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-043] step=1/1 "已让 DocumentContent 优先消费服务端 pageSubtree 快照,仅在标题或内容偏离服务端快照时才本地重建。"
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-043] (commit skipped by repo rule; base=f9a45e89)
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-044] step=1/1 "已补 pageSubtree 响应单测,跑通前端全量测试、首页 smoke,并确认当前 3000 主链未因 3104/Convex 挂起。"
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-044] (commit skipped by repo rule; base=f9a45e89)
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] STATS tasks_total=44 completed=44 failed=0 pending=0 blocked=0 attempts_total=35 checkpoints=41
|
||||
[2026-04-18T02:00:05Z] [SESSION-6] LOCK released
|
||||
[2026-04-18T02:09:30Z] [MANUAL] BACKLOG appended task-045..task-069 with approved order: Kernel Phase 7 Rust pageSubtree/read_view cutover before AI-first Rust block editor baseline
|
||||
[2026-04-18T02:09:30Z] [MANUAL] STATS tasks_total=69 completed=44 failed=0 pending=25 blocked=0 attempts_total=35 checkpoints=41
|
||||
[2026-04-18T02:20:00Z] [SESSION-7] LOCK acquired (pid=codex)
|
||||
[2026-04-18T02:20:00Z] [SESSION-7] INIT Harness resumed for Kernel Phase 7 Rust pageSubtree/read_view cutover and block editor baseline
|
||||
[2026-04-18T02:20:00Z] [SESSION-7] Starting [task-045] 执行 Kernel Phase 7-4:把 pageSubtree/read_view 真正下沉到 bridge-runtime 与 mnote-web 输出边界,退出前端 projection 生成主路径 (base=f9a45e89)
|
||||
[2026-04-18T02:45:00Z] [MANUAL] Reordered harness backlog: removed early 3000/3104/Convex guardrails from task-046/task-047 and BlockEditor Phase 3/5 validations; moved cross-runtime coordination to post-editor task-070/task-071 per latest user instruction.
|
||||
[2026-04-18T03:13:39Z] [SESSION-8] LOCK acquired (pid=422517)
|
||||
[2026-04-18T03:13:39Z] [SESSION-8] RECOVERY [task-045] action="resume-with-worktree" reason="检测到与 task-045 对应的文档页 pageSubtree/read_view 未提交改动,先保留并继续验证,不执行回滚。"
|
||||
[2026-04-18T03:13:39Z] [SESSION-8] INIT Harness resumed for task-045 continuation and block editor backlog preparation
|
||||
[2026-04-18T03:13:39Z] [SESSION-8] INIT Environment health check: PASS
|
||||
[2026-04-18T03:33:58Z] [SESSION-8] CHECKPOINT [task-045] step=1/2 "已为 documents.content.get 补齐 Rust page_subtree 输出、mnote-web /api/documents/content route 与对应测试,cargo test -p bridge-runtime / mnote-web 通过。"
|
||||
[2026-04-18T03:38:08Z] [SESSION-8] CHECKPOINT [task-045] step=2/2 "已切除前端 pageSubtree 主路径 fallback,文档页现在只消费 Rust 输出契约;pnpm vitest / pnpm test 通过。"
|
||||
[2026-04-18T03:38:08Z] [SESSION-8] Completed [task-045] (commit skipped by repo rule; base=f9a45e89)
|
||||
[2026-04-18T03:38:08Z] [SESSION-8] Starting [task-046] 执行 Kernel Phase 7-5:让 Next 文档页 SSR 与 /api/documents/content 优先消费 Rust 输出契约,移除前端 pageSubtree 主路径 fallback (base=f9a45e89)
|
||||
[2026-04-18T03:38:08Z] [SESSION-8] CHECKPOINT [task-046] step=1/1 "已让 page-subtree-response、DocumentContent、DocumentReadView 退出 pageSubtree 主路径 fallback,SSR/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/comments;task103 与 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 report;ai_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 cleanup(task-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 core,kode/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 QA:pnpm 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 模板只作为体验 benchmark,mnote-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
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
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 |
Generated
+10
@@ -1381,12 +1381,20 @@ dependencies = [
|
||||
"bridge-runtime",
|
||||
"clap",
|
||||
"core-protocol",
|
||||
"mnote-editor-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"storage-convex-bridge",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mnote-editor-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mnote-web"
|
||||
version = "0.1.0"
|
||||
@@ -1397,9 +1405,11 @@ dependencies = [
|
||||
"core-protocol",
|
||||
"futures-util",
|
||||
"leptos",
|
||||
"mnote-editor-core",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"storage-convex-bridge",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/core-domain",
|
||||
"crates/core-protocol",
|
||||
"crates/event-log",
|
||||
"crates/mnote-editor-core",
|
||||
"crates/mnote-cli",
|
||||
"crates/mnote-web",
|
||||
"crates/storage-convex-bridge",
|
||||
|
||||
@@ -244,7 +244,10 @@ pub fn prepare_proxy_request(
|
||||
}
|
||||
|
||||
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() {
|
||||
add_allowed(hostname, &port);
|
||||
if is_local_hostname(hostname) {
|
||||
@@ -260,7 +263,10 @@ pub fn prepare_proxy_request(
|
||||
}
|
||||
|
||||
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() {
|
||||
add_allowed(hostname, &port);
|
||||
if is_local_hostname(hostname) {
|
||||
@@ -275,7 +281,10 @@ pub fn prepare_proxy_request(
|
||||
.host_str()
|
||||
.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());
|
||||
}
|
||||
|
||||
@@ -303,8 +312,14 @@ pub fn prepare_proxy_request(
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(anon_key) = input.supabase_anon_key.filter(|value| !value.trim().is_empty()) {
|
||||
let matches_public = supa.as_ref().map(|(value, _)| value == hostname).unwrap_or(false);
|
||||
if let Some(anon_key) = input
|
||||
.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
|
||||
.as_ref()
|
||||
.and_then(|value| value.host_str())
|
||||
@@ -332,7 +347,10 @@ pub fn prepare_callback(
|
||||
workspace_id: input.workspace_id.clone(),
|
||||
document_id: input.document_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 {
|
||||
@@ -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 payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
||||
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());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
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()?;
|
||||
return Some((
|
||||
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()))
|
||||
@@ -574,7 +595,9 @@ mod tests {
|
||||
supabase_anon_key: Some("anon".into()),
|
||||
})
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -587,7 +610,9 @@ mod tests {
|
||||
user_id: Some("user_1".into()),
|
||||
session_id: None,
|
||||
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()),
|
||||
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>,
|
||||
}
|
||||
@@ -352,6 +352,121 @@ pub struct KernelProjectionResult {
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelCreateNode {
|
||||
@@ -508,7 +623,10 @@ mod tests {
|
||||
assert_eq!(value["rowId"], json!("asset:page_1"));
|
||||
assert_eq!(value["rowKind"], json!("asset"));
|
||||
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["iconHint"], json!("page"));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod command;
|
||||
pub mod common;
|
||||
pub mod editor;
|
||||
pub mod governance;
|
||||
pub mod kernel;
|
||||
pub mod mindmap;
|
||||
@@ -17,39 +18,49 @@ pub use common::{
|
||||
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
||||
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::{
|
||||
KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode,
|
||||
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
|
||||
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
|
||||
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
|
||||
KernelNodeType, KernelProjectionAssetKind, KernelProjectionCapability,
|
||||
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
||||
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
||||
DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
|
||||
KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult,
|
||||
KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
||||
};
|
||||
pub use mindmap::{
|
||||
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode,
|
||||
};
|
||||
pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode};
|
||||
pub use query::{
|
||||
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks,
|
||||
ListSidebarDataset, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset,
|
||||
QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||
};
|
||||
pub use tool::{
|
||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||
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,
|
||||
DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||
INDEX_TOOL_REBUILD, MINDMAP_TOOL_APPLY_OPS,
|
||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
||||
MINDMAP_TOOL_GET_SUBTREE,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ,
|
||||
MINDMAP_TOOLSET_WRITE, OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||
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_GET_SUBTREE,
|
||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
|
||||
ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, ONLYOFFICE_TOOLSET_SERVICE,
|
||||
RECOVERY_TOOLSET_JOB, REPLAY_TOOL_EVENTS,
|
||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, RECOVERY_TOOLSET_JOB,
|
||||
REPLAY_TOOL_EVENTS,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -164,9 +175,15 @@ mod tests {
|
||||
.toolset("toolset.doc_write")
|
||||
.expect("doc_write toolset should exist");
|
||||
assert!(doc_write.write_toolset);
|
||||
assert_eq!(doc_write.tool_names, &["doc_insert_blocks", "doc_replace_range"]);
|
||||
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"
|
||||
);
|
||||
let mindmap_write = registry
|
||||
@@ -203,7 +220,11 @@ mod tests {
|
||||
assert!(!observe.write_toolset);
|
||||
assert_eq!(
|
||||
observe.tool_names,
|
||||
&["bridge_request_get", "bridge_trace_get", "bridge_command_get"]
|
||||
&[
|
||||
"bridge_request_get",
|
||||
"bridge_trace_get",
|
||||
"bridge_command_get"
|
||||
]
|
||||
);
|
||||
let recovery = registry
|
||||
.toolset("toolset.recovery_job")
|
||||
|
||||
@@ -71,8 +71,7 @@ impl ToolRegistry {
|
||||
}
|
||||
|
||||
pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> {
|
||||
self.toolset(id)
|
||||
.map(|toolset| toolset.tool_names.to_vec())
|
||||
self.toolset(id).map(|toolset| toolset.tool_names.to_vec())
|
||||
}
|
||||
|
||||
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,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||
@@ -117,11 +115,9 @@ pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||
};
|
||||
|
||||
|
||||
pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
name: "docs_search",
|
||||
display_name: "跨页文档搜索",
|
||||
@@ -130,8 +126,7 @@ pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
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"}}}"#,
|
||||
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"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
||||
@@ -142,8 +137,7 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
||||
input_schema_json: 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 {
|
||||
@@ -154,8 +148,7 @@ pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
||||
@@ -166,8 +159,7 @@ pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
|
||||
input_schema_json: 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 {
|
||||
@@ -178,8 +170,7 @@ pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
|
||||
input_schema_json: 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 {
|
||||
@@ -190,8 +181,7 @@ pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
|
||||
input_schema_json: 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 {
|
||||
@@ -202,8 +192,7 @@ pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
||||
input_schema_json: r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
||||
@@ -214,8 +203,7 @@ pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
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}}}"#,
|
||||
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}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
||||
@@ -226,8 +214,7 @@ pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
||||
@@ -238,8 +225,7 @@ pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
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"}}}"#,
|
||||
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"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
||||
@@ -250,8 +236,7 @@ pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
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"}}}"#,
|
||||
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"}}}"#,
|
||||
};
|
||||
|
||||
pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
||||
@@ -262,8 +247,7 @@ pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Command,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
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,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
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}}}}}}"#,
|
||||
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}}}}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
||||
@@ -286,8 +269,7 @@ pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
||||
@@ -298,8 +280,7 @@ pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
||||
@@ -310,8 +291,7 @@ pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Query,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
||||
@@ -322,8 +302,7 @@ pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
||||
@@ -334,8 +313,7 @@ pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: true,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
||||
@@ -346,8 +324,7 @@ pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
|
||||
input_schema_json: 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 {
|
||||
@@ -358,8 +335,7 @@ pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
||||
@@ -370,8 +346,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Read,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
||||
input_schema_json: r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
||||
@@ -382,8 +357,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: false,
|
||||
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"}}}"#,
|
||||
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"}}}"#,
|
||||
};
|
||||
|
||||
pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
||||
@@ -394,8 +368,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
||||
invocation_kind: InvocationKind::Job,
|
||||
effect: ToolEffect::Write,
|
||||
requires_confirmation: false,
|
||||
input_schema_json:
|
||||
r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
|
||||
input_schema_json: 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 {
|
||||
@@ -406,7 +379,6 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
tool_names: &["doc_get", "doc_find"],
|
||||
};
|
||||
|
||||
|
||||
pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
id: "toolset.docs_read",
|
||||
display_name: "跨页文档读取",
|
||||
@@ -466,7 +438,11 @@ pub const OBSERVE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||
display_name: "统一观测",
|
||||
description: "按 request、trace、command 回查统一日志与事件视图。",
|
||||
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 {
|
||||
|
||||
@@ -273,7 +273,9 @@ pub fn search_pages(
|
||||
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| {
|
||||
document
|
||||
.title
|
||||
@@ -376,7 +378,8 @@ pub fn evaluate_search_documents(
|
||||
request.custom_range_to.as_deref(),
|
||||
),
|
||||
_ => within_range(
|
||||
document.updated_at
|
||||
document
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(document.created_at.as_deref()),
|
||||
boundary_iso.as_deref(),
|
||||
@@ -386,7 +389,10 @@ pub fn evaluate_search_documents(
|
||||
})
|
||||
.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
|
||||
.iter()
|
||||
.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();
|
||||
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(
|
||||
&mut matches,
|
||||
&asset.document_id,
|
||||
@@ -552,7 +561,14 @@ pub fn evaluate_search_documents(
|
||||
score: 1.7,
|
||||
match_field: SearchMatchField::Content,
|
||||
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,
|
||||
),
|
||||
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 {
|
||||
value.split_whitespace().collect::<Vec<_>>().join(" ").trim().to_string()
|
||||
value
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn escape_html(value: &str) -> String {
|
||||
@@ -1090,7 +1111,10 @@ mod tests {
|
||||
|
||||
assert_eq!(result.results.len(), 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].subtree_root_id.as_deref(), Some("page_1"));
|
||||
assert_eq!(result.results[0].evidence.len(), 1);
|
||||
|
||||
@@ -10,6 +10,7 @@ base64 = "0.22"
|
||||
bridge-runtime = { path = "../bridge-runtime" }
|
||||
clap = { version = "4.5.38", features = ["derive"] }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -3,6 +3,7 @@ use bridge_runtime::{
|
||||
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
|
||||
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use core_protocol::{
|
||||
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
|
||||
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
|
||||
@@ -10,6 +11,11 @@ use core_protocol::{
|
||||
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
|
||||
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 serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
@@ -130,6 +136,80 @@ pub struct CliTransportPlan {
|
||||
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)]
|
||||
pub struct PageCreateArgs<'a> {
|
||||
pub page_id: &'a str,
|
||||
@@ -194,6 +274,8 @@ pub fn supported_command_surface() -> Vec<&'static str> {
|
||||
"search documents",
|
||||
"search blocks",
|
||||
"sidebar dataset",
|
||||
"editor markdown-roundtrip",
|
||||
"editor session-demo",
|
||||
"tool run",
|
||||
];
|
||||
for tool_name in default_tool_registry().tool_names() {
|
||||
@@ -223,6 +305,8 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
|
||||
"search.documents",
|
||||
"search.blocks",
|
||||
"sidebar.dataset",
|
||||
"editor.markdown_roundtrip",
|
||||
"editor.session_demo",
|
||||
"tool.run",
|
||||
];
|
||||
for tool_name in default_tool_registry().tool_names() {
|
||||
@@ -231,6 +315,179 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
|
||||
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 {
|
||||
match &output.operation {
|
||||
CliOperationOutput::Command {
|
||||
@@ -2359,6 +2616,10 @@ fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
|
||||
CliError::validation(error.message)
|
||||
}
|
||||
|
||||
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
|
||||
CliError::validation(error.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2374,9 +2635,121 @@ mod tests {
|
||||
assert!(commands.contains(&"mindmap get"));
|
||||
assert!(commands.contains(&"search documents"));
|
||||
assert!(commands.contains(&"sidebar dataset"));
|
||||
assert!(commands.contains(&"editor markdown-roundtrip"));
|
||||
assert!(commands.contains(&"editor session-demo"));
|
||||
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]
|
||||
fn page_save_json_contract_uses_documents_save() {
|
||||
let output = plan_page_save(
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use core_protocol::{InvocationKind, ToolExecutionMode};
|
||||
use mnote_cli::{
|
||||
execute_output, plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch,
|
||||
plan_mindmap_get, plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete,
|
||||
plan_page_get, plan_page_move, plan_page_restore, plan_page_save, plan_page_title,
|
||||
plan_search_blocks, plan_search_documents, plan_sidebar_dataset, plan_tool_run,
|
||||
editor_ai_pipeline, editor_markdown_roundtrip, editor_session_demo, execute_output,
|
||||
plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch, plan_mindmap_get,
|
||||
plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete, plan_page_get,
|
||||
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,
|
||||
PageCreateArgs, PageDeleteArgs, PageMoveArgs, PageRestoreArgs,
|
||||
EditorAiScenarioArg, EditorCliOutput, EditorInputKindArg, PageCreateArgs, PageDeleteArgs,
|
||||
PageMoveArgs, PageRestoreArgs,
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -57,6 +59,7 @@ enum Commands {
|
||||
Mindmap(MindmapCommand),
|
||||
Search(SearchCommand),
|
||||
Sidebar(SidebarCommand),
|
||||
Editor(EditorCliCommand),
|
||||
Tool(ToolCommand),
|
||||
}
|
||||
|
||||
@@ -370,6 +373,43 @@ struct SidebarDatasetArgs {
|
||||
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)]
|
||||
struct ToolCommand {
|
||||
#[command(subcommand)]
|
||||
@@ -422,6 +462,11 @@ fn main() {
|
||||
dry_run: cli.global.dry_run,
|
||||
};
|
||||
|
||||
enum CommandOutcome {
|
||||
Bridge(CliJsonOutput),
|
||||
Editor(EditorCliOutput),
|
||||
}
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Page(command) => match command.action {
|
||||
PageAction::Get(args) => {
|
||||
@@ -475,7 +520,8 @@ fn main() {
|
||||
workspace_id: args.workspace_id.as_deref(),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Block(command) => match command.action {
|
||||
BlockAction::Insert(args) => plan_block_insert(
|
||||
&context,
|
||||
@@ -512,7 +558,8 @@ fn main() {
|
||||
target_block_id: args.target_block_id.as_deref(),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Mindmap(command) => match command.action {
|
||||
MindmapAction::Get(args) => plan_mindmap_get(
|
||||
&context,
|
||||
@@ -535,7 +582,8 @@ fn main() {
|
||||
&args.mindmap_id,
|
||||
&args.ops_json,
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Search(command) => match command.action {
|
||||
SearchAction::Documents(args) => plan_search_documents(
|
||||
&context,
|
||||
@@ -551,10 +599,22 @@ fn main() {
|
||||
args.limit,
|
||||
args.cursor.as_deref(),
|
||||
),
|
||||
},
|
||||
}
|
||||
.map(CommandOutcome::Bridge),
|
||||
Commands::Sidebar(command) => match command.action {
|
||||
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 {
|
||||
ToolAction::Run(args) => plan_tool_run(
|
||||
&context,
|
||||
@@ -563,18 +623,23 @@ fn main() {
|
||||
to_execution_mode(args.mode),
|
||||
&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 {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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) -> ! {
|
||||
if json_mode {
|
||||
println!(
|
||||
@@ -625,3 +701,22 @@ fn to_execution_mode(mode: ToolModeArg) -> ToolExecutionMode {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
@@ -11,9 +11,11 @@ bridge-runtime = { path = "../bridge-runtime" }
|
||||
core-protocol = { path = "../core-protocol" }
|
||||
futures-util = "0.3"
|
||||
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"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
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
@@ -1,6 +1,8 @@
|
||||
mod bridge;
|
||||
mod command_support;
|
||||
mod compat;
|
||||
mod documents;
|
||||
mod editor;
|
||||
mod health;
|
||||
mod hermes;
|
||||
mod kernel;
|
||||
@@ -22,6 +24,15 @@ pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.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(
|
||||
"/api/tree/projections/sidebar",
|
||||
get(kernel::project_tree_sidebar),
|
||||
|
||||
@@ -7,7 +7,9 @@ use bridge_runtime::{
|
||||
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
|
||||
RuntimeSourceWire,
|
||||
};
|
||||
use core_protocol::{GetPageMeta, QueryEnvelope};
|
||||
use serde_json::Value;
|
||||
use storage_convex_bridge::{build_query_request, BridgeContext};
|
||||
|
||||
pub fn resolve_effective_workspace_id(
|
||||
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(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
@@ -96,6 +123,34 @@ pub fn build_runtime_query_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(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -106,6 +161,16 @@ pub async fn fetch_query_data_via_convex(
|
||||
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(
|
||||
context: &RequestContext,
|
||||
effective_workspace_id: Option<&str>,
|
||||
|
||||
@@ -261,7 +261,8 @@ mod tests {
|
||||
page_id: "page_2".into(),
|
||||
workspace_id: Some("ws_1".into()),
|
||||
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()),
|
||||
},
|
||||
reason: Some("嵌入页面".into()),
|
||||
@@ -274,7 +275,9 @@ mod tests {
|
||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||
assert_eq!(request.function_name, "documents:updateContent");
|
||||
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]
|
||||
@@ -769,9 +772,7 @@ mod tests {
|
||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||
assert_eq!(request.function_name, "search:recent");
|
||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||
assert!(request
|
||||
.payload_json
|
||||
.contains("\"name\":\"search.recent\""));
|
||||
assert!(request.payload_json.contains("\"name\":\"search.recent\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,6 +28,34 @@ impl BridgeError {
|
||||
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>;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.89.0"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
+2173
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 @@
|
||||
{"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);
|
||||
});
|
||||
@@ -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 UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||
const TEST_USERNAME_PREFIX = "测试用户";
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
@@ -92,19 +93,101 @@ async function getViewerIdentity(requestContext) {
|
||||
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) {
|
||||
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")) {
|
||||
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 page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
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) {
|
||||
@@ -137,7 +220,7 @@ async function cleanupDocuments(requestContext, createdIds) {
|
||||
|
||||
async function openDocument(page, workspaceId, documentId) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
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";
|
||||
|
||||
interface DocumentPageProps {
|
||||
@@ -48,15 +49,18 @@ type DocumentContentPayload = {
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
page_subtree?: PageSubtreeProjection | null;
|
||||
};
|
||||
|
||||
async function fetchDocumentContentOnServer(input: {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
title?: string | null;
|
||||
}): Promise<{
|
||||
content: unknown;
|
||||
revision: number | null;
|
||||
conflictDetectionKey: string | null;
|
||||
pageSubtree: PageSubtreeProjection | null;
|
||||
}> {
|
||||
try {
|
||||
const headerList = await headers();
|
||||
@@ -101,23 +105,19 @@ async function fetchDocumentContentOnServer(input: {
|
||||
client,
|
||||
plan,
|
||||
});
|
||||
const normalized = normalizeDocumentContentResponse({
|
||||
documentId: input.documentId,
|
||||
title: input.title ?? null,
|
||||
payload: result,
|
||||
});
|
||||
|
||||
return {
|
||||
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`,
|
||||
};
|
||||
return normalized;
|
||||
} catch {
|
||||
return {
|
||||
content: null,
|
||||
revision: null,
|
||||
conflictDetectionKey: null,
|
||||
pageSubtree: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -169,11 +169,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
const initialDocumentContent = await fetchDocumentContentOnServer({
|
||||
documentId: doc.id,
|
||||
workspaceId: doc.workspace_id,
|
||||
});
|
||||
const initialPageSubtree = buildPageSubtreeProjection({
|
||||
documentId: doc.id,
|
||||
title: doc.title ?? "无标题",
|
||||
content: initialDocumentContent.content,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -187,7 +183,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
initialContent={initialDocumentContent.content}
|
||||
initialContentRevision={initialDocumentContent.revision}
|
||||
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
|
||||
initialPageSubtree={initialPageSubtree}
|
||||
initialPageSubtree={initialDocumentContent.pageSubtree}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
openTableId={openTableId}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
buildDocumentQueryEnvelope,
|
||||
documentBridgeErrorResponse,
|
||||
} from "@/lib/documents/bridge";
|
||||
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
resolveRustBridgeQueryPlan,
|
||||
@@ -35,6 +37,8 @@ export async function GET(request: Request) {
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflict_detection_key?: string | null;
|
||||
title?: string | null;
|
||||
page_subtree?: PageSubtreeProjection | null;
|
||||
} | null>({
|
||||
client,
|
||||
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({
|
||||
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
|
||||
: `${documentId}:0`,
|
||||
content: normalized.content,
|
||||
revision: normalized.revision,
|
||||
conflictDetectionKey: normalized.conflictDetectionKey,
|
||||
pageSubtree: normalized.pageSubtree,
|
||||
meta: {
|
||||
requestId: bridgeContext.requestId,
|
||||
traceId: bridgeContext.traceId,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DocumentToc } from "@/components/editor/document-toc";
|
||||
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 {
|
||||
deleteDocumentCommand,
|
||||
embedDocumentCommand,
|
||||
@@ -143,6 +143,11 @@ export function DocumentContent({
|
||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
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 [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
|
||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||
@@ -259,6 +264,14 @@ export function DocumentContent({
|
||||
setPageTitle(title ?? "无标题");
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerPageSubtreeTitle(title ?? "无标题");
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerPageSubtreeSnapshot(initialPageSubtree);
|
||||
}, [initialPageSubtree]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(initialOptions ?? defaultOptions);
|
||||
}, [initialOptions]);
|
||||
@@ -275,6 +288,10 @@ export function DocumentContent({
|
||||
setConflictDetectionKey(initialConflictDetectionKey);
|
||||
}, [initialConflictDetectionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerContentSnapshot(initialContent);
|
||||
}, [initialContent]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextBlocks = extractPageBlocks(content);
|
||||
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
|
||||
@@ -354,9 +371,11 @@ export function DocumentContent({
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
pageSubtree?: PageSubtreeProjection | null;
|
||||
};
|
||||
if (canceled) return;
|
||||
setContent(payload.content ?? null);
|
||||
setServerContentSnapshot(payload.content ?? null);
|
||||
setContentRevision(
|
||||
typeof payload.revision === "number" && Number.isInteger(payload.revision)
|
||||
? payload.revision
|
||||
@@ -367,6 +386,13 @@ export function DocumentContent({
|
||||
? payload.conflictDetectionKey
|
||||
: `${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) {
|
||||
if (canceled) return;
|
||||
if ((error as { name?: string })?.name === "AbortError") return;
|
||||
@@ -393,7 +419,7 @@ export function DocumentContent({
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [documentId, initialContent, contentReloadKey, workspaceId]);
|
||||
}, [documentId, initialContent, contentReloadKey, title, workspaceId]);
|
||||
|
||||
const persistTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
@@ -730,18 +756,24 @@ export function DocumentContent({
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
const pageSubtree = useMemo(() => {
|
||||
if (initialPageSubtree && content === initialContent && pageTitle === (title ?? "无标题")) {
|
||||
return initialPageSubtree;
|
||||
const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
|
||||
const titleUnchanged = pageTitle === serverPageSubtreeTitle;
|
||||
const contentUnchanged = content === serverContentSnapshot;
|
||||
|
||||
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
|
||||
return serverPageSubtreeSnapshot;
|
||||
}
|
||||
return buildPageSubtreeProjection({
|
||||
documentId,
|
||||
title: pageTitle,
|
||||
content,
|
||||
});
|
||||
}, [content, documentId, initialContent, initialPageSubtree, pageTitle, title]);
|
||||
return null;
|
||||
}, [
|
||||
content,
|
||||
pageTitle,
|
||||
serverContentSnapshot,
|
||||
serverPageSubtreeSnapshot,
|
||||
serverPageSubtreeTitle,
|
||||
]);
|
||||
const readViewTocEntries = useMemo(
|
||||
() =>
|
||||
pageSubtree.outline
|
||||
(pageSubtree?.outline ?? [])
|
||||
.filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim())
|
||||
.map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({
|
||||
id: anchorBlockId as string,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { CSSProperties, ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TocEntry } from "@/components/editor/document-toc";
|
||||
import {
|
||||
buildPageSubtreeProjection,
|
||||
clampHeadingLevel,
|
||||
extractPageBlocks,
|
||||
getInlineText,
|
||||
@@ -181,11 +180,26 @@ const buildHeadingNumberingMapFromOutline = (outline: PageOutlineEntry[]): Map<s
|
||||
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
|
||||
|
||||
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
|
||||
return buildPageSubtreeProjection({
|
||||
documentId: "preview",
|
||||
title: "预览",
|
||||
content: blocks,
|
||||
}).outline.map(({ id, level, numbering, title }) => ({
|
||||
const counters = [0, 0, 0, 0, 0];
|
||||
return blocks.flatMap((block) => {
|
||||
if (block.type !== "heading") {
|
||||
return [];
|
||||
}
|
||||
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,
|
||||
level,
|
||||
numbering,
|
||||
@@ -583,22 +597,15 @@ function DocumentReadStructurePanel({ pageSubtree }: { pageSubtree: PageSubtreeP
|
||||
|
||||
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
|
||||
const blocks = extractReadViewBlocks(content);
|
||||
const resolvedPageSubtree =
|
||||
pageSubtree ??
|
||||
buildPageSubtreeProjection({
|
||||
documentId,
|
||||
title: null,
|
||||
content,
|
||||
});
|
||||
const headingNumberingById =
|
||||
resolvedPageSubtree.outline.length > 0
|
||||
? buildHeadingNumberingMapFromOutline(resolvedPageSubtree.outline)
|
||||
(pageSubtree?.outline.length ?? 0) > 0
|
||||
? buildHeadingNumberingMapFromOutline(pageSubtree?.outline ?? [])
|
||||
: buildHeadingNumberingMap(blocks);
|
||||
|
||||
if (blocks.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
@@ -608,7 +615,7 @@ export function DocumentReadView({ content, documentId, options, pageSubtree, cl
|
||||
|
||||
return (
|
||||
<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)}
|
||||
</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,
|
||||
};
|
||||
}
|
||||
@@ -73,6 +73,36 @@ const parseRuntimeBoolean = (value: unknown): boolean | 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 => ({
|
||||
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
|
||||
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
@@ -111,12 +141,14 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
|
||||
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
||||
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
||||
|
||||
// 说明:这里不能直接写 `require("fs")` / `require("path")`,
|
||||
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖,触发持续重编译或空白页。
|
||||
// 仅在服务端运行时通过惰性 require 读取本地 public/mnote-env.json。
|
||||
const runtimeRequire = new Function("return require")() as NodeJS.Require;
|
||||
const fs = runtimeRequire("node:fs") as typeof import("fs");
|
||||
const path = runtimeRequire("node:path") as typeof import("path");
|
||||
// 说明:这里不能直接静态引入 `node:fs` / `node:path`,
|
||||
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。
|
||||
// Node 22 优先走 `process.getBuiltinModule`,其余环境再兜底到 runtime require。
|
||||
const fs = getServerNodeBuiltin<typeof import("fs")>("node:fs");
|
||||
const path = getServerNodeBuiltin<typeof import("path")>("node:path");
|
||||
if (!fs || !path) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`,
|
||||
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
|
||||
@@ -224,18 +256,28 @@ export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
|
||||
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
|
||||
}
|
||||
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
||||
const publicRuntime = readFromPublicJson();
|
||||
const envRuntime = readFromEnv();
|
||||
// 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
|
||||
// Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被
|
||||
// public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
|
||||
const merged: MnoteRuntimeConfig = isDesktop
|
||||
? {
|
||||
...readFromEnv(),
|
||||
...readFromPublicJson(),
|
||||
...envRuntime,
|
||||
...publicRuntime,
|
||||
isDesktop,
|
||||
}
|
||||
: {
|
||||
...readFromPublicJson(),
|
||||
...readFromEnv(),
|
||||
...publicRuntime,
|
||||
...envRuntime,
|
||||
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
|
||||
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
|
||||
...(publicRuntime.mnoteWebBaseUrl !== undefined
|
||||
? { mnoteWebBaseUrl: publicRuntime.mnoteWebBaseUrl }
|
||||
: {}),
|
||||
...(publicRuntime.mnoteWebTreeShellEnabled !== undefined
|
||||
? { mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled }
|
||||
: {}),
|
||||
isDesktop,
|
||||
};
|
||||
return normalizeRuntimeConfig(merged);
|
||||
|
||||
Reference in New Issue
Block a user