Fix tiptap selection sync and toolbar event loop
This commit is contained in:
@@ -53,6 +53,7 @@ design
|
|||||||
|
|
||||||
# Rust 本地构建与调试产物
|
# Rust 本地构建与调试产物
|
||||||
/rust/target/
|
/rust/target/
|
||||||
|
/rust/spikes/**/target/
|
||||||
|
|
||||||
# Harness 本地运行状态/调试产物
|
# Harness 本地运行状态/调试产物
|
||||||
/.harness-active
|
/.harness-active
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- generic [ref=e2]:
|
||||||
|
- heading "Example Domain" [level=1] [ref=e3]
|
||||||
|
- paragraph [ref=e4]: This domain is for use in documentation examples without needing permission. Avoid use in operations.
|
||||||
|
- paragraph [ref=e5]:
|
||||||
|
- link "Learn more" [ref=e6] [cursor=pointer]:
|
||||||
|
- /url: https://iana.org/domains/example
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- img [ref=e4]
|
||||||
|
- generic [ref=e7]: Connecting...
|
||||||
|
- alert [ref=e8]
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
- main [ref=e2]:
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- generic [ref=e4]:
|
||||||
|
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
|
||||||
|
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
|
||||||
|
- paragraph [ref=e7]:
|
||||||
|
- text: 这里继续只做
|
||||||
|
- code [ref=e8]: "8123"
|
||||||
|
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
|
||||||
|
- code [ref=e9]: /documents/[id]
|
||||||
|
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
|
||||||
|
- generic [ref=e10]:
|
||||||
|
- generic [ref=e11]:
|
||||||
|
- generic [ref=e12]: 变更计数
|
||||||
|
- strong [ref=e13]: "0"
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- generic [ref=e15]: Todo Schema
|
||||||
|
- strong [ref=e16]: pending
|
||||||
|
- generic [ref=e17]:
|
||||||
|
- generic [ref=e18]: Selection
|
||||||
|
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
|
||||||
|
- generic [ref=e20]:
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- generic [ref=e22]:
|
||||||
|
- generic [ref=e23]: 空间 / 主文档 /
|
||||||
|
- strong [ref=e24]: Editor Baseline Reset
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- generic [ref=e26]: 反馈:
|
||||||
|
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
|
||||||
|
- textbox "Spike 页面标题" [ref=e28]:
|
||||||
|
- /placeholder: 给这篇页面起个标题
|
||||||
|
- text: Leptos Tiptap 主编辑器 P0
|
||||||
|
- generic [ref=e29]:
|
||||||
|
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
|
||||||
|
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
|
||||||
|
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
|
||||||
|
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
|
||||||
|
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
|
||||||
|
- generic [ref=e35]:
|
||||||
|
- generic [ref=e36]:
|
||||||
|
- generic [ref=e37]:
|
||||||
|
- button "Bold" [ref=e38] [cursor=pointer]
|
||||||
|
- button "Italic" [ref=e39] [cursor=pointer]
|
||||||
|
- button "Strike" [ref=e40] [cursor=pointer]
|
||||||
|
- button "Code" [ref=e41] [cursor=pointer]
|
||||||
|
- button "Highlight" [ref=e42] [cursor=pointer]
|
||||||
|
- button "Link" [ref=e43] [cursor=pointer]
|
||||||
|
- button "Turn into" [ref=e45] [cursor=pointer]
|
||||||
|
- generic [ref=e46]:
|
||||||
|
- button "Undo" [ref=e47] [cursor=pointer]
|
||||||
|
- button "Redo" [ref=e48] [cursor=pointer]
|
||||||
|
- textbox [ref=e50]:
|
||||||
|
- heading "准备接管主编辑器体验" [level=1] [ref=e51]
|
||||||
|
- paragraph [ref=e52]:
|
||||||
|
- text: 这页现在先只在
|
||||||
|
- strong [ref=e53]: "8123"
|
||||||
|
- text: 做 P0 体验收口:你可以输入
|
||||||
|
- code [ref=e54]: /
|
||||||
|
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
|
||||||
|
- paragraph [ref=e55]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
|
||||||
|
- list [ref=e56]:
|
||||||
|
- listitem [ref=e57]:
|
||||||
|
- paragraph [ref=e58]: 标题、段落、引用、代码块
|
||||||
|
- listitem [ref=e59]:
|
||||||
|
- paragraph [ref=e60]: 无序列表 / 有序列表 / Todo 列表
|
||||||
|
- listitem [ref=e61]:
|
||||||
|
- paragraph [ref=e62]: 分割线、行内样式、链接和高亮
|
||||||
|
- paragraph [ref=e63]:
|
||||||
|
- text: 请把光标放到这里,然后按
|
||||||
|
- code [ref=e64]: /
|
||||||
|
- text: 。
|
||||||
|
- generic [ref=e65]:
|
||||||
|
- generic [ref=e66]:
|
||||||
|
- text: 当前 P0 边界:
|
||||||
|
- code [ref=e67]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
|
||||||
|
- generic [ref=e68]:
|
||||||
|
- text: 运行时扩展不再走
|
||||||
|
- code [ref=e69]: TiptapExtension::all_enabled()
|
||||||
|
- text: ,这里已冻结为最小集合;当前 reload 走
|
||||||
|
- code [ref=e70]: localStorage JSON
|
||||||
|
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
|
||||||
|
- group [ref=e71]:
|
||||||
|
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e72] [cursor=pointer]:
|
||||||
|
- generic [ref=e73]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
|
||||||
|
- generic [ref=e74]: 展开 / 收起
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
- main [ref=e2]:
|
||||||
|
- generic [ref=e3]:
|
||||||
|
- generic [ref=e4]:
|
||||||
|
- generic [ref=e5]: P0 / Tiptap-like 主编辑器体验
|
||||||
|
- heading "把 Spike 收敛成能检验的最小产品页壳" [level=1] [ref=e6]
|
||||||
|
- paragraph [ref=e7]:
|
||||||
|
- text: 这里继续只做
|
||||||
|
- code [ref=e8]: "8123"
|
||||||
|
- text: 的 Leptos + Tiptap P0 体验验证,不挪动正式
|
||||||
|
- code [ref=e9]: /documents/[id]
|
||||||
|
- text: 主链。目标是先把 slash、toolbar、todo schema、块手柄和 reload 做成可直接看得见、点得动、能刷新保留结构的网页。
|
||||||
|
- generic [ref=e10]:
|
||||||
|
- generic [ref=e11]:
|
||||||
|
- generic [ref=e12]: 变更计数
|
||||||
|
- strong [ref=e13]: "0"
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- generic [ref=e15]: Todo Schema
|
||||||
|
- strong [ref=e16]: pending
|
||||||
|
- generic [ref=e17]:
|
||||||
|
- generic [ref=e18]: Selection
|
||||||
|
- strong [ref=e19]: 当前块:一级标题 | 行内样式:无行内样式
|
||||||
|
- generic [ref=e20]:
|
||||||
|
- generic [ref=e21]:
|
||||||
|
- generic [ref=e22]:
|
||||||
|
- generic [ref=e23]: 空间 / 主文档 /
|
||||||
|
- strong [ref=e24]: Editor Baseline Reset
|
||||||
|
- generic [ref=e25]:
|
||||||
|
- generic [ref=e26]: 反馈:
|
||||||
|
- strong [ref=e27]: 编辑器已准备就绪,当前内容已写入本地草稿
|
||||||
|
- textbox "Spike 页面标题" [ref=e28]:
|
||||||
|
- /placeholder: 给这篇页面起个标题
|
||||||
|
- text: Leptos Tiptap 主编辑器 P0
|
||||||
|
- generic [ref=e29]:
|
||||||
|
- generic [ref=e30]: 输入 “/” 打开真正的最小 slash 菜单
|
||||||
|
- generic [ref=e31]: 选中文本时会出现最小浮动工具条
|
||||||
|
- generic [ref=e32]: Todo 现在走 taskList / taskItem,而不是占位 HTML
|
||||||
|
- generic [ref=e33]: 把鼠标移到块左侧,点击 ⠿ 打开块菜单并拖拽重排
|
||||||
|
- generic [ref=e34]: 本页会把 JSON 草稿写入 localStorage,刷新后不丢结构
|
||||||
|
- generic [ref=e35]:
|
||||||
|
- generic [ref=e36]:
|
||||||
|
- generic [ref=e37]:
|
||||||
|
- button "Bold" [ref=e38] [cursor=pointer]
|
||||||
|
- button "Italic" [ref=e39] [cursor=pointer]
|
||||||
|
- button "Strike" [ref=e40] [cursor=pointer]
|
||||||
|
- button "Code" [ref=e41] [cursor=pointer]
|
||||||
|
- button "Highlight" [ref=e42] [cursor=pointer]
|
||||||
|
- button "Link" [ref=e43] [cursor=pointer]
|
||||||
|
- button "Turn into" [ref=e45] [cursor=pointer]
|
||||||
|
- generic [ref=e46]:
|
||||||
|
- button "Undo" [ref=e47] [cursor=pointer]
|
||||||
|
- button "Redo" [ref=e48] [cursor=pointer]
|
||||||
|
- textbox [ref=e50]:
|
||||||
|
- heading "准备接管主编辑器体验" [level=1] [ref=e51]
|
||||||
|
- paragraph [ref=e52]:
|
||||||
|
- text: 这页现在先只在
|
||||||
|
- strong [ref=e53]: "8123"
|
||||||
|
- text: 做 P0 体验收口:你可以输入
|
||||||
|
- code [ref=e54]: /
|
||||||
|
- text: 打开命令菜单,也可以直接试试选中文字后的浮动工具条。
|
||||||
|
- paragraph [ref=e55]: 目标不是继续堆 runtime debug,而是先把页面做得更像可以真实检验的块编辑器。
|
||||||
|
- list [ref=e56]:
|
||||||
|
- listitem [ref=e57]:
|
||||||
|
- paragraph [ref=e58]: 标题、段落、引用、代码块
|
||||||
|
- listitem [ref=e59]:
|
||||||
|
- paragraph [ref=e60]: 无序列表 / 有序列表 / Todo 列表
|
||||||
|
- listitem [ref=e61]:
|
||||||
|
- paragraph [ref=e62]: 分割线、行内样式、链接和高亮
|
||||||
|
- paragraph [ref=e63]:
|
||||||
|
- text: 请把光标放到这里,然后按
|
||||||
|
- code [ref=e64]: /
|
||||||
|
- text: 。
|
||||||
|
- generic [ref=e65]:
|
||||||
|
- generic [ref=e66]:
|
||||||
|
- text: 当前 P0 边界:
|
||||||
|
- code [ref=e67]: paragraph / heading1-3 / bullet / ordered / todo / quote / code block / divider
|
||||||
|
- generic [ref=e68]:
|
||||||
|
- text: 运行时扩展不再走
|
||||||
|
- code [ref=e69]: TiptapExtension::all_enabled()
|
||||||
|
- text: ,这里已冻结为最小集合;当前 reload 走
|
||||||
|
- code [ref=e70]: localStorage JSON
|
||||||
|
- text: ,块菜单支持复制、删除、turn into 与顶层拖拽。
|
||||||
|
- group [ref=e71]:
|
||||||
|
- generic "调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面 展开 / 收起" [ref=e72] [cursor=pointer]:
|
||||||
|
- generic [ref=e73]: 调试抽屉:保留 HTML / JSON / Selection 观测,但不再主导页面
|
||||||
|
- generic [ref=e74]: 展开 / 收起
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- alert [ref=e8]
|
||||||
|
- generic [ref=e9]:
|
||||||
|
- banner [ref=e10]:
|
||||||
|
- generic [ref=e11]:
|
||||||
|
- group [ref=e12]:
|
||||||
|
- group [ref=e13]:
|
||||||
|
- button "Undo" [disabled] [ref=e14]:
|
||||||
|
- img [ref=e15]
|
||||||
|
- group [ref=e17]:
|
||||||
|
- button "Redo" [disabled] [ref=e18]:
|
||||||
|
- img [ref=e19]
|
||||||
|
- separator [ref=e21]
|
||||||
|
- button "Switch to dark mode" [ref=e22] [cursor=pointer]:
|
||||||
|
- img [ref=e23]
|
||||||
|
- separator [ref=e33]
|
||||||
|
- button [ref=e34] [cursor=pointer]
|
||||||
|
- generic [ref=e40]:
|
||||||
|
- generic [ref=e41]:
|
||||||
|
- paragraph [ref=e42]: Write, type '/' for commands…
|
||||||
|
- heading "Welcome to Notion-like template sparkles emoji" [level=1] [ref=e43]:
|
||||||
|
- text: Welcome to Notion-like template
|
||||||
|
- img "sparkles emoji" [ref=e45]
|
||||||
|
- blockquote [ref=e46]:
|
||||||
|
- paragraph [ref=e47]:
|
||||||
|
- img "love_letter emoji" [ref=e49]
|
||||||
|
- strong [ref=e50]: Invite your colleagues to make this fun!
|
||||||
|
- text: Just copy the URL from your browser and share it – everyone with the link can join in and collaborate in real time.
|
||||||
|
- generic:
|
||||||
|
- generic: John Anderson
|
||||||
|
- paragraph [ref=e51]:
|
||||||
|
- text: Start writing your thoughts here …
|
||||||
|
- img "pencil2 emoji" [ref=e53]
|
||||||
|
- text: Try some
|
||||||
|
- strong [ref=e54]: "Markdown:"
|
||||||
|
- paragraph [ref=e55]: /
|
||||||
|
- code [ref=e57]: "# Headings - Lists > Quotes `Inline code`"
|
||||||
|
- paragraph [ref=e58]:
|
||||||
|
- text: Or type
|
||||||
|
- code [ref=e59]: /
|
||||||
|
- text: to open the command menu and discover blocks, formatting, and hidden features.
|
||||||
|
- separator [ref=e61]
|
||||||
|
- heading "Make it yours" [level=1] [ref=e62]
|
||||||
|
- list [ref=e63]:
|
||||||
|
- listitem [ref=e64]:
|
||||||
|
- paragraph [ref=e65]:
|
||||||
|
- strong [ref=e66]: Select text
|
||||||
|
- text: "to reveal a floating toolbar:"
|
||||||
|
- emphasis [ref=e67]: Quickly italicize,
|
||||||
|
- emphasis [ref=e69]: color
|
||||||
|
- emphasis [ref=e70]: ", add"
|
||||||
|
- link "links" [ref=e71]:
|
||||||
|
- /url: https://tiptap.dev/docs/ui-components/getting-started/overview
|
||||||
|
- emphasis [ref=e72]: links
|
||||||
|
- emphasis [ref=e73]:
|
||||||
|
- text: ", or"
|
||||||
|
- mark [ref=e74]: highlight text
|
||||||
|
- text: just as you're used to..
|
||||||
|
- listitem [ref=e75]:
|
||||||
|
- paragraph [ref=e76]:
|
||||||
|
- strong [ref=e77]: Hover near any block
|
||||||
|
- text: to reveal the context handle
|
||||||
|
- code [ref=e78]: ⠿
|
||||||
|
- emphasis [ref=e79]: Click to open the context menu (duplicate, delete, reset formatting, and more) or simply drag to move your content anywhere you like!
|
||||||
|
- listitem [ref=e80]:
|
||||||
|
- paragraph [ref=e81]:
|
||||||
|
- text: Mention teammates with
|
||||||
|
- code [ref=e82]: "@"
|
||||||
|
- text: and add some fun with emoji
|
||||||
|
- code [ref=e83]: ":"
|
||||||
|
- img "partying_face emoji" [ref=e85]
|
||||||
|
- listitem [ref=e86]:
|
||||||
|
- paragraph [ref=e87]:
|
||||||
|
- text: Switch between
|
||||||
|
- img "sunny emoji" [ref=e89]
|
||||||
|
- text: light and
|
||||||
|
- img "new_moon emoji" [ref=e91]
|
||||||
|
- text: dark mode – whatever fits your mood.
|
||||||
|
- img "Screenshot 2025-02-23 at 19.22.27" [ref=e96] [cursor=pointer]
|
||||||
|
- blockquote [ref=e97]:
|
||||||
|
- paragraph [ref=e98]:
|
||||||
|
- strong [ref=e99]: Need a spark?
|
||||||
|
- img "sparkles emoji" [ref=e101]
|
||||||
|
- strong [ref=e102]
|
||||||
|
- text: Summon the AI Assistant with
|
||||||
|
- code [ref=e103]: /ask ai
|
||||||
|
- text: from the context menu, or by selecting text and choosing
|
||||||
|
- code [ref=e104]: Improve
|
||||||
|
- text: .
|
||||||
|
- text: Polish your writing, or try a ready-made prompt—the AI menu appears with helpful suggestions.
|
||||||
|
- heading "white_check_mark emoji Interactive Tables Included" [level=3] [ref=e105]:
|
||||||
|
- img "white_check_mark emoji" [ref=e107]
|
||||||
|
- strong [ref=e108]
|
||||||
|
- text: Interactive Tables Included
|
||||||
|
- paragraph [ref=e109]: This template comes with the full-featured table component. Click row or column handles for sorting and formatting, use the extend buttons (+ icons) to add rows or columns, drag and drop to reorder rows, and select cells to format or merge content.
|
||||||
|
- table [ref=e113]:
|
||||||
|
- rowgroup [ref=e119]:
|
||||||
|
- row "Name Role Department Location" [ref=e120]:
|
||||||
|
- columnheader "Name" [ref=e121]:
|
||||||
|
- paragraph [ref=e122]: Name
|
||||||
|
- columnheader "Role" [ref=e123]:
|
||||||
|
- paragraph [ref=e124]: Role
|
||||||
|
- columnheader "Department" [ref=e125]:
|
||||||
|
- paragraph [ref=e126]: Department
|
||||||
|
- columnheader "Location" [ref=e127]:
|
||||||
|
- paragraph [ref=e128]: Location
|
||||||
|
- row "Alice Johnson Senior Developer Engineering San Francisco" [ref=e129]:
|
||||||
|
- cell "Alice Johnson" [ref=e130]:
|
||||||
|
- paragraph [ref=e131]: Alice Johnson
|
||||||
|
- cell "Senior Developer" [ref=e132]:
|
||||||
|
- paragraph [ref=e133]: Senior Developer
|
||||||
|
- cell "Engineering" [ref=e134]:
|
||||||
|
- paragraph [ref=e135]: Engineering
|
||||||
|
- cell "San Francisco" [ref=e136]:
|
||||||
|
- paragraph [ref=e137]: San Francisco
|
||||||
|
- row "Bob Smith Product Manager Product New York" [ref=e138]:
|
||||||
|
- cell "Bob Smith" [ref=e139]:
|
||||||
|
- paragraph [ref=e140]: Bob Smith
|
||||||
|
- cell "Product Manager" [ref=e141]:
|
||||||
|
- paragraph [ref=e142]: Product Manager
|
||||||
|
- cell "Product" [ref=e143]:
|
||||||
|
- paragraph [ref=e144]: Product
|
||||||
|
- cell "New York" [ref=e145]:
|
||||||
|
- paragraph [ref=e146]: New York
|
||||||
|
- row "Carol White UX Designer Design London" [ref=e147]:
|
||||||
|
- cell "Carol White" [ref=e148]:
|
||||||
|
- paragraph [ref=e149]: Carol White
|
||||||
|
- cell "UX Designer" [ref=e150]:
|
||||||
|
- paragraph [ref=e151]: UX Designer
|
||||||
|
- cell "Design" [ref=e152]:
|
||||||
|
- paragraph [ref=e153]: Design
|
||||||
|
- cell "London" [ref=e154]:
|
||||||
|
- paragraph [ref=e155]: London
|
||||||
|
- row "David Chen Data Analyst Analytics Remote" [ref=e156]:
|
||||||
|
- cell "David Chen" [ref=e157]:
|
||||||
|
- paragraph [ref=e158]: David Chen
|
||||||
|
- cell "Data Analyst" [ref=e159]:
|
||||||
|
- paragraph [ref=e160]: Data Analyst
|
||||||
|
- cell "Analytics" [ref=e161]:
|
||||||
|
- paragraph [ref=e162]: Analytics
|
||||||
|
- cell "Remote" [ref=e163]:
|
||||||
|
- paragraph [ref=e164]: Remote
|
||||||
|
- heading "Table of Content" [level=2] [ref=e165]
|
||||||
|
- generic [ref=e167]:
|
||||||
|
- generic [ref=e168]: Table of contents
|
||||||
|
- navigation "Table of contents" [ref=e169]:
|
||||||
|
- link "Welcome to Notion-like template" [ref=e170] [cursor=pointer]:
|
||||||
|
- /url: "#8e84d1f3-8d16-4370-94e7-46252c8428d8"
|
||||||
|
- link "Make it yours" [ref=e171] [cursor=pointer]:
|
||||||
|
- /url: "#9a020862-49f3-43e0-a697-202b41566a0c"
|
||||||
|
- link "Interactive Tables Included" [ref=e172] [cursor=pointer]:
|
||||||
|
- /url: "#c76b9241-350c-4c58-a37f-0d5784ea4f50"
|
||||||
|
- link "Table of Content" [ref=e173] [cursor=pointer]:
|
||||||
|
- /url: "#6a90ecef-245d-4f75-9846-4fb16bb606ba"
|
||||||
|
- link "Checklist" [ref=e174] [cursor=pointer]:
|
||||||
|
- /url: "#bf962ab5-f0a0-43f7-8473-de72f4a2b8ae"
|
||||||
|
- link "Developer quickstart" [ref=e175] [cursor=pointer]:
|
||||||
|
- /url: "#e2ba59b4-1014-4875-9992-552bdc74521c"
|
||||||
|
- link "Short description" [ref=e176] [cursor=pointer]:
|
||||||
|
- /url: "#1741b659-1e29-4da3-9fb0-269418e78f79"
|
||||||
|
- link "P.S." [ref=e177] [cursor=pointer]:
|
||||||
|
- /url: "#ac284b90-febc-4403-bf34-5d287956ed25"
|
||||||
|
- heading "Checklist" [level=2] [ref=e178]
|
||||||
|
- list [ref=e179]:
|
||||||
|
- listitem [ref=e180]:
|
||||||
|
- generic [ref=e181]:
|
||||||
|
- checkbox "Task item checkbox for Read up to this point" [checked]
|
||||||
|
- paragraph [ref=e184]: Read up to this point
|
||||||
|
- listitem [ref=e185]:
|
||||||
|
- generic [ref=e186]:
|
||||||
|
- checkbox "Task item checkbox for Try a slash command"
|
||||||
|
- paragraph [ref=e189]: Try a slash command
|
||||||
|
- listitem [ref=e190]:
|
||||||
|
- generic [ref=e191]:
|
||||||
|
- checkbox "Task item checkbox for Mention someone"
|
||||||
|
- paragraph [ref=e194]: Mention someone
|
||||||
|
- listitem [ref=e195]:
|
||||||
|
- generic [ref=e196]:
|
||||||
|
- checkbox "Task item checkbox for Use the floating toolbar"
|
||||||
|
- paragraph [ref=e199]: Use the floating toolbar
|
||||||
|
- listitem [ref=e200]:
|
||||||
|
- generic [ref=e201]:
|
||||||
|
- checkbox "Task item checkbox for Add a color highlight"
|
||||||
|
- paragraph [ref=e204]: Add a color highlight
|
||||||
|
- listitem [ref=e205]:
|
||||||
|
- generic [ref=e206]:
|
||||||
|
- checkbox "Task item checkbox for Explore the context menu & drag blocks"
|
||||||
|
- paragraph [ref=e209]: Explore the context menu & drag blocks
|
||||||
|
- listitem [ref=e210]:
|
||||||
|
- generic [ref=e211]:
|
||||||
|
- checkbox "Task item checkbox for Ask the AI for help"
|
||||||
|
- paragraph [ref=e214]: Ask the AI for help
|
||||||
|
- separator [ref=e216]
|
||||||
|
- heading "Developer quickstart" [level=1] [ref=e217]
|
||||||
|
- paragraph [ref=e218]: "Ready to build your own editor? Just run:"
|
||||||
|
- code [ref=e220]: npx @tiptap/cli init
|
||||||
|
- blockquote [ref=e221]:
|
||||||
|
- paragraph [ref=e222]:
|
||||||
|
- strong [ref=e223]: Did you know?
|
||||||
|
- text: Many features here are powered by open-source Tiptap UI Components. Some advanced tools – like the AI Assistant, advanced color palettes, or context menus – are exclusive to paid users.
|
||||||
|
- text: Unlock even more possibilities by
|
||||||
|
- link "upgrading your plan" [ref=e224]:
|
||||||
|
- /url: https://tiptap.dev/pricing
|
||||||
|
- text: "!"
|
||||||
|
- heading "Short description" [level=3] [ref=e225]
|
||||||
|
- list [ref=e226]:
|
||||||
|
- listitem [ref=e227]:
|
||||||
|
- paragraph [ref=e228]: Content blocks = Node Components
|
||||||
|
- listitem [ref=e229]:
|
||||||
|
- paragraph [ref=e230]: Toolbars, menus, and buttons = UI Components
|
||||||
|
- separator [ref=e232]
|
||||||
|
- heading "P.S." [level=2] [ref=e233]
|
||||||
|
- paragraph [ref=e234]: 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] Completed [task-041] (commit skipped by repo rule)
|
||||||
[2026-04-18T01:37:10Z] [SESSION-5] STATS tasks_total=41 completed=41 failed=0 pending=0 blocked=0 attempts_total=32 checkpoints=38
|
[2026-04-18T01:37:10Z] [SESSION-5] STATS tasks_total=41 completed=41 failed=0 pending=0 blocked=0 attempts_total=32 checkpoints=38
|
||||||
[2026-04-18T01:37:10Z] [SESSION-5] LOCK released
|
[2026-04-18T01:37:10Z] [SESSION-5] LOCK released
|
||||||
|
[2026-04-18T01:48:47Z] [SESSION-6] LOCK acquired (pid=codex)
|
||||||
|
[2026-04-18T01:48:47Z] [SESSION-6] INIT Harness resumed for Kernel Phase 7 document read-view/pageSubtree batch task-042..task-044
|
||||||
|
[2026-04-18T01:48:47Z] [SESSION-6] INIT Environment health check: PASS
|
||||||
|
[2026-04-18T01:48:47Z] [SESSION-6] Starting [task-042] 执行 Kernel Phase 7-1:把文档页 pageSubtree 固化为服务端首包与 /api/documents/content 稳定响应契约,减少前端读链即时拼装 (base=f9a45e89)
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-042] step=1/1 "已把 pageSubtree 固化为 SSR 首包与 /api/documents/content 响应契约,并补齐共享归一化 helper 与单测。"
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-042] (commit skipped by repo rule; base=f9a45e89)
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-043] step=1/1 "已让 DocumentContent 优先消费服务端 pageSubtree 快照,仅在标题或内容偏离服务端快照时才本地重建。"
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-043] (commit skipped by repo rule; base=f9a45e89)
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] CHECKPOINT [task-044] step=1/1 "已补 pageSubtree 响应单测,跑通前端全量测试、首页 smoke,并确认当前 3000 主链未因 3104/Convex 挂起。"
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] Completed [task-044] (commit skipped by repo rule; base=f9a45e89)
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] STATS tasks_total=44 completed=44 failed=0 pending=0 blocked=0 attempts_total=35 checkpoints=41
|
||||||
|
[2026-04-18T02:00:05Z] [SESSION-6] LOCK released
|
||||||
|
[2026-04-18T02:09:30Z] [MANUAL] BACKLOG appended task-045..task-069 with approved order: Kernel Phase 7 Rust pageSubtree/read_view cutover before AI-first Rust block editor baseline
|
||||||
|
[2026-04-18T02:09:30Z] [MANUAL] STATS tasks_total=69 completed=44 failed=0 pending=25 blocked=0 attempts_total=35 checkpoints=41
|
||||||
|
[2026-04-18T02:20:00Z] [SESSION-7] LOCK acquired (pid=codex)
|
||||||
|
[2026-04-18T02:20:00Z] [SESSION-7] INIT Harness resumed for Kernel Phase 7 Rust pageSubtree/read_view cutover and block editor baseline
|
||||||
|
[2026-04-18T02:20:00Z] [SESSION-7] Starting [task-045] 执行 Kernel Phase 7-4:把 pageSubtree/read_view 真正下沉到 bridge-runtime 与 mnote-web 输出边界,退出前端 projection 生成主路径 (base=f9a45e89)
|
||||||
|
[2026-04-18T02:45:00Z] [MANUAL] Reordered harness backlog: removed early 3000/3104/Convex guardrails from task-046/task-047 and BlockEditor Phase 3/5 validations; moved cross-runtime coordination to post-editor task-070/task-071 per latest user instruction.
|
||||||
|
[2026-04-18T03:13:39Z] [SESSION-8] LOCK acquired (pid=422517)
|
||||||
|
[2026-04-18T03:13:39Z] [SESSION-8] RECOVERY [task-045] action="resume-with-worktree" reason="检测到与 task-045 对应的文档页 pageSubtree/read_view 未提交改动,先保留并继续验证,不执行回滚。"
|
||||||
|
[2026-04-18T03:13:39Z] [SESSION-8] INIT Harness resumed for task-045 continuation and block editor backlog preparation
|
||||||
|
[2026-04-18T03:13:39Z] [SESSION-8] INIT Environment health check: PASS
|
||||||
|
[2026-04-18T03:33:58Z] [SESSION-8] CHECKPOINT [task-045] step=1/2 "已为 documents.content.get 补齐 Rust page_subtree 输出、mnote-web /api/documents/content route 与对应测试,cargo test -p bridge-runtime / mnote-web 通过。"
|
||||||
|
[2026-04-18T03:38:08Z] [SESSION-8] CHECKPOINT [task-045] step=2/2 "已切除前端 pageSubtree 主路径 fallback,文档页现在只消费 Rust 输出契约;pnpm vitest / pnpm test 通过。"
|
||||||
|
[2026-04-18T03:38:08Z] [SESSION-8] Completed [task-045] (commit skipped by repo rule; base=f9a45e89)
|
||||||
|
[2026-04-18T03:38:08Z] [SESSION-8] Starting [task-046] 执行 Kernel Phase 7-5:让 Next 文档页 SSR 与 /api/documents/content 优先消费 Rust 输出契约,移除前端 pageSubtree 主路径 fallback (base=f9a45e89)
|
||||||
|
[2026-04-18T03:38:08Z] [SESSION-8] CHECKPOINT [task-046] step=1/1 "已让 page-subtree-response、DocumentContent、DocumentReadView 退出 pageSubtree 主路径 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",
|
"bridge-runtime",
|
||||||
"clap",
|
"clap",
|
||||||
"core-protocol",
|
"core-protocol",
|
||||||
|
"mnote-editor-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"storage-convex-bridge",
|
"storage-convex-bridge",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mnote-editor-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mnote-web"
|
name = "mnote-web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1397,9 +1405,11 @@ dependencies = [
|
|||||||
"core-protocol",
|
"core-protocol",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"leptos",
|
"leptos",
|
||||||
|
"mnote-editor-core",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"storage-convex-bridge",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ members = [
|
|||||||
"crates/core-domain",
|
"crates/core-domain",
|
||||||
"crates/core-protocol",
|
"crates/core-protocol",
|
||||||
"crates/event-log",
|
"crates/event-log",
|
||||||
|
"crates/mnote-editor-core",
|
||||||
"crates/mnote-cli",
|
"crates/mnote-cli",
|
||||||
"crates/mnote-web",
|
"crates/mnote-web",
|
||||||
"crates/storage-convex-bridge",
|
"crates/storage-convex-bridge",
|
||||||
|
|||||||
@@ -244,7 +244,10 @@ pub fn prepare_proxy_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(origin) = supa_internal_origin.as_ref() {
|
if let Some(origin) = supa_internal_origin.as_ref() {
|
||||||
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
let port = origin
|
||||||
|
.port()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
if let Some(hostname) = origin.host_str() {
|
if let Some(hostname) = origin.host_str() {
|
||||||
add_allowed(hostname, &port);
|
add_allowed(hostname, &port);
|
||||||
if is_local_hostname(hostname) {
|
if is_local_hostname(hostname) {
|
||||||
@@ -260,7 +263,10 @@ pub fn prepare_proxy_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(origin) = convex_origin.as_ref() {
|
if let Some(origin) = convex_origin.as_ref() {
|
||||||
let port = origin.port().map(|value| value.to_string()).unwrap_or_default();
|
let port = origin
|
||||||
|
.port()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
if let Some(hostname) = origin.host_str() {
|
if let Some(hostname) = origin.host_str() {
|
||||||
add_allowed(hostname, &port);
|
add_allowed(hostname, &port);
|
||||||
if is_local_hostname(hostname) {
|
if is_local_hostname(hostname) {
|
||||||
@@ -275,7 +281,10 @@ pub fn prepare_proxy_request(
|
|||||||
.host_str()
|
.host_str()
|
||||||
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
|
.ok_or_else(|| "目标 URL 缺少 hostname".to_string())?;
|
||||||
|
|
||||||
if is_private_ipv4(hostname) && !is_local_hostname(hostname) && !allowed_hostnames.contains(hostname) {
|
if is_private_ipv4(hostname)
|
||||||
|
&& !is_local_hostname(hostname)
|
||||||
|
&& !allowed_hostnames.contains(hostname)
|
||||||
|
{
|
||||||
return Err("禁止访问内网/私有地址".into());
|
return Err("禁止访问内网/私有地址".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,8 +312,14 @@ pub fn prepare_proxy_request(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(anon_key) = input.supabase_anon_key.filter(|value| !value.trim().is_empty()) {
|
if let Some(anon_key) = input
|
||||||
let matches_public = supa.as_ref().map(|(value, _)| value == hostname).unwrap_or(false);
|
.supabase_anon_key
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
let matches_public = supa
|
||||||
|
.as_ref()
|
||||||
|
.map(|(value, _)| value == hostname)
|
||||||
|
.unwrap_or(false);
|
||||||
let matches_internal = supa_internal_origin
|
let matches_internal = supa_internal_origin
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|value| value.host_str())
|
.and_then(|value| value.host_str())
|
||||||
@@ -332,7 +347,10 @@ pub fn prepare_callback(
|
|||||||
workspace_id: input.workspace_id.clone(),
|
workspace_id: input.workspace_id.clone(),
|
||||||
document_id: input.document_id.clone(),
|
document_id: input.document_id.clone(),
|
||||||
user_id: input.user_id.clone(),
|
user_id: input.user_id.clone(),
|
||||||
session_id: input.session_id.clone().or_else(|| Some("onlyoffice-callback".into())),
|
session_id: input
|
||||||
|
.session_id
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some("onlyoffice-callback".into())),
|
||||||
});
|
});
|
||||||
|
|
||||||
if input.status != 2 && input.status != 6 {
|
if input.status != 2 && input.status != 6 {
|
||||||
@@ -464,7 +482,8 @@ fn sign_hs256(payload: &Value, secret: &str) -> Result<String, String> {
|
|||||||
let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
|
let header_part = URL_SAFE_NO_PAD.encode(header.to_string().as_bytes());
|
||||||
let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
let payload_part = URL_SAFE_NO_PAD.encode(payload.to_string().as_bytes());
|
||||||
let signing_input = format!("{header_part}.{payload_part}");
|
let signing_input = format!("{header_part}.{payload_part}");
|
||||||
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
|
let mut mac =
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).map_err(|error| error.to_string())?;
|
||||||
mac.update(signing_input.as_bytes());
|
mac.update(signing_input.as_bytes());
|
||||||
let signature = mac.finalize().into_bytes();
|
let signature = mac.finalize().into_bytes();
|
||||||
let signature_part = URL_SAFE_NO_PAD.encode(signature);
|
let signature_part = URL_SAFE_NO_PAD.encode(signature);
|
||||||
@@ -512,7 +531,9 @@ fn try_parse_origin_host(raw: Option<&str>) -> Option<(String, String)> {
|
|||||||
let url = Url::parse(value).ok()?;
|
let url = Url::parse(value).ok()?;
|
||||||
return Some((
|
return Some((
|
||||||
url.host_str()?.to_string(),
|
url.host_str()?.to_string(),
|
||||||
url.port().map(|value| value.to_string()).unwrap_or_default(),
|
url.port()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some((value.to_string(), String::new()))
|
Some((value.to_string(), String::new()))
|
||||||
@@ -574,7 +595,9 @@ mod tests {
|
|||||||
supabase_anon_key: Some("anon".into()),
|
supabase_anon_key: Some("anon".into()),
|
||||||
})
|
})
|
||||||
.expect("proxy should prepare");
|
.expect("proxy should prepare");
|
||||||
assert!(result.target_url.starts_with("http://127.0.0.1:18000/storage/v1/"));
|
assert!(result
|
||||||
|
.target_url
|
||||||
|
.starts_with("http://127.0.0.1:18000/storage/v1/"));
|
||||||
assert_eq!(result.forward_headers.len(), 2);
|
assert_eq!(result.forward_headers.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +610,9 @@ mod tests {
|
|||||||
user_id: Some("user_1".into()),
|
user_id: Some("user_1".into()),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
status: 6,
|
status: 6,
|
||||||
url: Some("http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into()),
|
url: Some(
|
||||||
|
"http://app.example.com/onlyoffice-server/cache/files/out.docx?token=1".into(),
|
||||||
|
),
|
||||||
key: Some("doc_key_1".into()),
|
key: Some("doc_key_1".into()),
|
||||||
onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
|
onlyoffice_internal_url: "http://127.0.0.1:8082".into(),
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
|||||||
|
use crate::editor::model::{BlockProps, ContentNode, EditorBlock, EditorBlockType, ReferenceToken};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EditorCommandKind {
|
||||||
|
ReplaceBlock,
|
||||||
|
InsertBlockAfter,
|
||||||
|
DeleteBlock,
|
||||||
|
SplitBlock,
|
||||||
|
MergeWithPrevious,
|
||||||
|
MoveBlock,
|
||||||
|
IndentBlock,
|
||||||
|
OutdentBlock,
|
||||||
|
ToggleHeadingCollapse,
|
||||||
|
AttachReferenceToken,
|
||||||
|
DetachReferenceToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorReplaceBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
pub block_type: Option<EditorBlockType>,
|
||||||
|
pub props: Option<BlockProps>,
|
||||||
|
pub content_nodes: Option<Vec<ContentNode>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorInsertBlockAfter {
|
||||||
|
pub after_block_id: String,
|
||||||
|
pub block: EditorBlock,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorDeleteBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
pub preserve_children: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorSplitBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
pub content_node_index: u32,
|
||||||
|
pub text_offset: Option<u32>,
|
||||||
|
pub trailing_block_type: Option<EditorBlockType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorMergeWithPrevious {
|
||||||
|
pub block_id: String,
|
||||||
|
pub previous_block_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorMoveBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
pub parent_block_id: Option<String>,
|
||||||
|
pub after_block_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorIndentBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
pub max_depth: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorOutdentBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorToggleHeadingCollapse {
|
||||||
|
pub block_id: String,
|
||||||
|
pub collapsed: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorAttachReferenceToken {
|
||||||
|
pub block_id: String,
|
||||||
|
pub content_node_index: u32,
|
||||||
|
pub token: ReferenceToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorDetachReferenceToken {
|
||||||
|
pub block_id: String,
|
||||||
|
pub content_node_index: u32,
|
||||||
|
pub target_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum EditorCommand {
|
||||||
|
ReplaceBlock(EditorReplaceBlock),
|
||||||
|
InsertBlockAfter(EditorInsertBlockAfter),
|
||||||
|
DeleteBlock(EditorDeleteBlock),
|
||||||
|
SplitBlock(EditorSplitBlock),
|
||||||
|
MergeWithPrevious(EditorMergeWithPrevious),
|
||||||
|
MoveBlock(EditorMoveBlock),
|
||||||
|
IndentBlock(EditorIndentBlock),
|
||||||
|
OutdentBlock(EditorOutdentBlock),
|
||||||
|
ToggleHeadingCollapse(EditorToggleHeadingCollapse),
|
||||||
|
AttachReferenceToken(EditorAttachReferenceToken),
|
||||||
|
DetachReferenceToken(EditorDetachReferenceToken),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorKernelCommandMapping {
|
||||||
|
pub editor_command: EditorCommandKind,
|
||||||
|
pub kernel_command_name: String,
|
||||||
|
pub lossy: bool,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorCommandDescriptor {
|
||||||
|
pub kind: EditorCommandKind,
|
||||||
|
pub label: String,
|
||||||
|
pub write_command: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorCommandCatalog {
|
||||||
|
#[serde(default)]
|
||||||
|
pub commands: Vec<EditorCommandDescriptor>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub kernel_command_mappings: Vec<EditorKernelCommandMapping>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use crate::editor::model::{EditorBlockDocument, ReferenceTokenStrategy};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarkdownFlavor {
|
||||||
|
Commonmark,
|
||||||
|
Gfm,
|
||||||
|
Mnote,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarkdownImportMode {
|
||||||
|
ReplaceDocument,
|
||||||
|
ReplaceSelection,
|
||||||
|
MergeIntoDocument,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MarkdownExportMode {
|
||||||
|
FullDocument,
|
||||||
|
Selection,
|
||||||
|
SingleBlock,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownImportBoundary {
|
||||||
|
pub allow_front_matter: bool,
|
||||||
|
pub allow_html_blocks: bool,
|
||||||
|
pub allow_reference_tokens: bool,
|
||||||
|
pub unsupported_blocks_as_paragraph: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownExportBoundary {
|
||||||
|
pub preserve_heading_collapse: bool,
|
||||||
|
pub preserve_reference_tokens: bool,
|
||||||
|
pub allow_lossy_block_fallback: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownImportOptions {
|
||||||
|
pub mode: MarkdownImportMode,
|
||||||
|
pub flavor: MarkdownFlavor,
|
||||||
|
pub reference_token_strategy: ReferenceTokenStrategy,
|
||||||
|
pub boundary: MarkdownImportBoundary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownExportOptions {
|
||||||
|
pub mode: MarkdownExportMode,
|
||||||
|
pub flavor: MarkdownFlavor,
|
||||||
|
pub reference_token_strategy: ReferenceTokenStrategy,
|
||||||
|
pub boundary: MarkdownExportBoundary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownImportRequest {
|
||||||
|
pub markdown: String,
|
||||||
|
pub options: MarkdownImportOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownImportResult {
|
||||||
|
pub document: EditorBlockDocument,
|
||||||
|
#[serde(default)]
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownExportRequest {
|
||||||
|
pub document: EditorBlockDocument,
|
||||||
|
pub options: MarkdownExportOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MarkdownExportResult {
|
||||||
|
pub markdown: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
pub mod command;
|
||||||
|
pub mod markdown;
|
||||||
|
pub mod model;
|
||||||
|
|
||||||
|
pub use command::{
|
||||||
|
EditorAttachReferenceToken, EditorCommand, EditorCommandCatalog, EditorCommandDescriptor,
|
||||||
|
EditorCommandKind, EditorDeleteBlock, EditorDetachReferenceToken, EditorIndentBlock,
|
||||||
|
EditorInsertBlockAfter, EditorKernelCommandMapping, EditorMergeWithPrevious, EditorMoveBlock,
|
||||||
|
EditorOutdentBlock, EditorReplaceBlock, EditorSplitBlock, EditorToggleHeadingCollapse,
|
||||||
|
};
|
||||||
|
pub use markdown::{
|
||||||
|
MarkdownExportBoundary, MarkdownExportMode, MarkdownExportOptions, MarkdownExportRequest,
|
||||||
|
MarkdownExportResult, MarkdownFlavor, MarkdownImportBoundary, MarkdownImportMode,
|
||||||
|
MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
|
||||||
|
};
|
||||||
|
pub use model::{
|
||||||
|
BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,
|
||||||
|
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_command_kind_uses_phase_one_names() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&EditorCommandKind::InsertBlockAfter).unwrap(),
|
||||||
|
"\"insert_block_after\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&EditorCommandKind::MergeWithPrevious).unwrap(),
|
||||||
|
"\"merge_with_previous\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&EditorCommandKind::ToggleHeadingCollapse).unwrap(),
|
||||||
|
"\"toggle_heading_collapse\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_block_type_uses_expected_snake_case_names() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&EditorBlockType::BulletListItem).unwrap(),
|
||||||
|
"\"bullet_list_item\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&EditorBlockType::PageReference).unwrap(),
|
||||||
|
"\"page_reference\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&EditorBlockType::BlockReference).unwrap(),
|
||||||
|
"\"block_reference\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EditorBlockType {
|
||||||
|
Paragraph,
|
||||||
|
Heading,
|
||||||
|
BulletListItem,
|
||||||
|
NumberedListItem,
|
||||||
|
Quote,
|
||||||
|
Todo,
|
||||||
|
CodeBlock,
|
||||||
|
PageReference,
|
||||||
|
BlockReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ReferenceTokenKind {
|
||||||
|
PageReference,
|
||||||
|
BlockReference,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ReferenceTokenStrategy {
|
||||||
|
DoubleBracket,
|
||||||
|
DoubleParen,
|
||||||
|
InlineChip,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TextMark {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Strike,
|
||||||
|
Code,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ReferenceToken {
|
||||||
|
pub kind: ReferenceTokenKind,
|
||||||
|
pub strategy: ReferenceTokenStrategy,
|
||||||
|
pub target_id: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub raw_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ContentNodePayload {
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
marks: Vec<TextMark>,
|
||||||
|
},
|
||||||
|
HardBreak,
|
||||||
|
ReferenceToken {
|
||||||
|
token: ReferenceToken,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ContentNode {
|
||||||
|
pub payload: ContentNodePayload,
|
||||||
|
#[serde(default)]
|
||||||
|
pub attrs: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BlockProps {
|
||||||
|
pub indent: Option<u16>,
|
||||||
|
pub heading_level: Option<u8>,
|
||||||
|
pub checked: Option<bool>,
|
||||||
|
pub collapsed: Option<bool>,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub reference_target_id: Option<String>,
|
||||||
|
pub reference_label: Option<String>,
|
||||||
|
pub reference_token_strategy: Option<ReferenceTokenStrategy>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorBlock {
|
||||||
|
pub block_id: String,
|
||||||
|
pub block_type: EditorBlockType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub props: BlockProps,
|
||||||
|
#[serde(default)]
|
||||||
|
pub content_nodes: Vec<ContentNode>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub child_block_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorBlockDocument {
|
||||||
|
pub document_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub root_block_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blocks: Vec<EditorBlock>,
|
||||||
|
}
|
||||||
@@ -352,6 +352,121 @@ pub struct KernelProjectionResult {
|
|||||||
pub edges: Vec<KernelEdge>,
|
pub edges: Vec<KernelEdge>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DocumentReadNodeType {
|
||||||
|
Page,
|
||||||
|
Section,
|
||||||
|
ContentNode,
|
||||||
|
ReferenceAnchor,
|
||||||
|
Mindmap,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentReadNodeMeta {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub text_snippet: Option<String>,
|
||||||
|
pub block_type: Option<String>,
|
||||||
|
pub heading_level: Option<u32>,
|
||||||
|
pub numbering: Option<String>,
|
||||||
|
pub child_count: u32,
|
||||||
|
pub order: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentReadNode {
|
||||||
|
pub id: String,
|
||||||
|
pub parent_node_id: Option<String>,
|
||||||
|
pub node_type: DocumentReadNodeType,
|
||||||
|
pub block_id: Option<String>,
|
||||||
|
pub anchor_block_id: Option<String>,
|
||||||
|
pub depth: u32,
|
||||||
|
pub metadata: DocumentReadNodeMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentReadSubtree {
|
||||||
|
pub root_node_id: String,
|
||||||
|
pub nodes: Vec<DocumentReadNode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentReadOutlineEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub node_id: String,
|
||||||
|
pub anchor_block_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub level: u32,
|
||||||
|
pub numbering: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DocumentReadEvidenceKind {
|
||||||
|
Page,
|
||||||
|
Heading,
|
||||||
|
Paragraph,
|
||||||
|
List,
|
||||||
|
Todo,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
Media,
|
||||||
|
Reference,
|
||||||
|
Table,
|
||||||
|
Mindmap,
|
||||||
|
Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentReadEvidenceItem {
|
||||||
|
pub id: String,
|
||||||
|
pub node_id: String,
|
||||||
|
pub block_id: Option<String>,
|
||||||
|
pub kind: DocumentReadEvidenceKind,
|
||||||
|
pub snippet: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentReadStats {
|
||||||
|
pub block_count: u32,
|
||||||
|
pub heading_count: u32,
|
||||||
|
pub evidence_count: u32,
|
||||||
|
pub max_depth: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentReadPageSubtree {
|
||||||
|
pub projection_id: String,
|
||||||
|
pub projection: String,
|
||||||
|
pub root_node_id: String,
|
||||||
|
pub root_node: DocumentReadNode,
|
||||||
|
pub subtree: DocumentReadSubtree,
|
||||||
|
#[serde(default)]
|
||||||
|
pub outline: Vec<DocumentReadOutlineEntry>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub evidence: Vec<DocumentReadEvidenceItem>,
|
||||||
|
pub stats: DocumentReadStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentContentResult {
|
||||||
|
pub content: Value,
|
||||||
|
pub revision: u64,
|
||||||
|
pub conflict_detection_key: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub page_subtree: DocumentReadPageSubtree,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct KernelCreateNode {
|
pub struct KernelCreateNode {
|
||||||
@@ -508,7 +623,10 @@ mod tests {
|
|||||||
assert_eq!(value["rowId"], json!("asset:page_1"));
|
assert_eq!(value["rowId"], json!("asset:page_1"));
|
||||||
assert_eq!(value["rowKind"], json!("asset"));
|
assert_eq!(value["rowKind"], json!("asset"));
|
||||||
assert_eq!(value["projectionKind"], json!("file_tree"));
|
assert_eq!(value["projectionKind"], json!("file_tree"));
|
||||||
assert_eq!(value["capabilities"], json!(["expand", "open", "create-child"]));
|
assert_eq!(
|
||||||
|
value["capabilities"],
|
||||||
|
json!(["expand", "open", "create-child"])
|
||||||
|
);
|
||||||
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
|
assert_eq!(value["resourceMeta"]["resourceKind"], json!("document"));
|
||||||
assert_eq!(value["iconHint"], json!("page"));
|
assert_eq!(value["iconHint"], json!("page"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod editor;
|
||||||
pub mod governance;
|
pub mod governance;
|
||||||
pub mod kernel;
|
pub mod kernel;
|
||||||
pub mod mindmap;
|
pub mod mindmap;
|
||||||
@@ -17,39 +18,49 @@ pub use common::{
|
|||||||
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
ActorPayload, AffectedObject, ErrorDetail, ErrorPayload, OkPayload, RequestMeta, ResponseMeta,
|
||||||
SourcePayload, TargetRef,
|
SourcePayload, TargetRef,
|
||||||
};
|
};
|
||||||
|
pub use editor::{
|
||||||
|
BlockProps, ContentNode, ContentNodePayload, EditorAttachReferenceToken, EditorBlock,
|
||||||
|
EditorBlockDocument, EditorBlockType, EditorCommand, EditorCommandCatalog,
|
||||||
|
EditorCommandDescriptor, EditorCommandKind, EditorDeleteBlock, EditorDetachReferenceToken,
|
||||||
|
EditorIndentBlock, EditorInsertBlockAfter, EditorKernelCommandMapping, EditorMergeWithPrevious,
|
||||||
|
EditorMoveBlock, EditorOutdentBlock, EditorReplaceBlock, EditorSplitBlock,
|
||||||
|
EditorToggleHeadingCollapse, MarkdownExportBoundary, MarkdownExportMode, MarkdownExportOptions,
|
||||||
|
MarkdownExportRequest, MarkdownExportResult, MarkdownFlavor, MarkdownImportBoundary,
|
||||||
|
MarkdownImportMode, MarkdownImportOptions, MarkdownImportRequest, MarkdownImportResult,
|
||||||
|
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
|
||||||
|
};
|
||||||
pub use kernel::{
|
pub use kernel::{
|
||||||
KernelAttachEdge, KernelAuditStamp, KernelContentPayload, KernelCreateNode,
|
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
|
||||||
KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType, KernelGetNode,
|
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
|
||||||
KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult, KernelGraphVisit,
|
DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
|
||||||
KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode, KernelNodeMetadata,
|
KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult,
|
||||||
KernelNodeType, KernelProjectionAssetKind, KernelProjectionCapability,
|
KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
|
||||||
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
|
||||||
|
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelProjectionAssetKind,
|
||||||
|
KernelProjectionCapability, KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind,
|
||||||
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta,
|
||||||
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef,
|
||||||
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode,
|
||||||
};
|
};
|
||||||
pub use mindmap::{
|
pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode};
|
||||||
MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode,
|
|
||||||
};
|
|
||||||
pub use query::{
|
pub use query::{
|
||||||
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage,
|
||||||
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks,
|
GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset,
|
||||||
ListSidebarDataset, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent,
|
||||||
};
|
};
|
||||||
pub use tool::{
|
pub use tool::{
|
||||||
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label,
|
||||||
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec,
|
||||||
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
ToolSpec, BRIDGE_TOOL_COMMAND_GET, BRIDGE_TOOL_REQUEST_GET, BRIDGE_TOOL_TRACE_GET,
|
||||||
DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOCS_TOOLSET_READ, DOC_TOOL_FIND, DOC_TOOL_GET,
|
DOCS_TOOLSET_READ, DOCS_TOOL_READ, DOCS_TOOL_SEARCH, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
||||||
DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE, DOC_TOOLSET_READ, DOC_TOOLSET_WRITE,
|
DOC_TOOL_FIND, DOC_TOOL_GET, DOC_TOOL_INSERT_BLOCKS, DOC_TOOL_REPLACE_RANGE,
|
||||||
INDEX_TOOL_REBUILD, MINDMAP_TOOL_APPLY_OPS,
|
INDEX_TOOL_REBUILD, MINDMAP_TOOLSET_READ, MINDMAP_TOOLSET_WRITE, MINDMAP_TOOL_APPLY_OPS,
|
||||||
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET,
|
MINDMAP_TOOL_EMPTY_TRASH, MINDMAP_TOOL_EXPAND_NODE, MINDMAP_TOOL_GET, MINDMAP_TOOL_GET_SUBTREE,
|
||||||
MINDMAP_TOOL_GET_SUBTREE,
|
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, OBSERVE_TOOLSET_READ,
|
||||||
MINDMAP_TOOL_OUTLINE_TO_MINDMAP, MINDMAP_TOOL_PUT, MINDMAP_TOOLSET_READ,
|
ONLYOFFICE_TOOLSET_SERVICE, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
||||||
MINDMAP_TOOLSET_WRITE, OBSERVE_TOOLSET_READ, ONLYOFFICE_TOOL_PREPARE_CALLBACK,
|
|
||||||
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
ONLYOFFICE_TOOL_PREPARE_FORCESAVE, ONLYOFFICE_TOOL_PREPARE_PROXY,
|
||||||
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, ONLYOFFICE_TOOLSET_SERVICE,
|
ONLYOFFICE_TOOL_SESSION_RESOLVE, ONLYOFFICE_TOOL_SIGN, RECOVERY_TOOLSET_JOB,
|
||||||
RECOVERY_TOOLSET_JOB, REPLAY_TOOL_EVENTS,
|
REPLAY_TOOL_EVENTS,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -164,9 +175,15 @@ mod tests {
|
|||||||
.toolset("toolset.doc_write")
|
.toolset("toolset.doc_write")
|
||||||
.expect("doc_write toolset should exist");
|
.expect("doc_write toolset should exist");
|
||||||
assert!(doc_write.write_toolset);
|
assert!(doc_write.write_toolset);
|
||||||
assert_eq!(doc_write.tool_names, &["doc_insert_blocks", "doc_replace_range"]);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.tool("doc_get").expect("doc_get should exist").toolset_id,
|
doc_write.tool_names,
|
||||||
|
&["doc_insert_blocks", "doc_replace_range"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.tool("doc_get")
|
||||||
|
.expect("doc_get should exist")
|
||||||
|
.toolset_id,
|
||||||
"toolset.doc_read"
|
"toolset.doc_read"
|
||||||
);
|
);
|
||||||
let mindmap_write = registry
|
let mindmap_write = registry
|
||||||
@@ -203,7 +220,11 @@ mod tests {
|
|||||||
assert!(!observe.write_toolset);
|
assert!(!observe.write_toolset);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
observe.tool_names,
|
observe.tool_names,
|
||||||
&["bridge_request_get", "bridge_trace_get", "bridge_command_get"]
|
&[
|
||||||
|
"bridge_request_get",
|
||||||
|
"bridge_trace_get",
|
||||||
|
"bridge_command_get"
|
||||||
|
]
|
||||||
);
|
);
|
||||||
let recovery = registry
|
let recovery = registry
|
||||||
.toolset("toolset.recovery_job")
|
.toolset("toolset.recovery_job")
|
||||||
|
|||||||
@@ -71,8 +71,7 @@ impl ToolRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> {
|
pub fn tool_names_in_set(&self, id: &str) -> Option<Vec<&'static str>> {
|
||||||
self.toolset(id)
|
self.toolset(id).map(|toolset| toolset.tool_names.to_vec())
|
||||||
.map(|toolset| toolset.tool_names.to_vec())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tools_in_set(&self, id: &str) -> Option<Vec<&'static ToolSpec>> {
|
pub fn tools_in_set(&self, id: &str) -> Option<Vec<&'static ToolSpec>> {
|
||||||
@@ -105,8 +104,7 @@ pub const SEARCH_WEB_TOOL: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
||||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"count":{"type":"integer","minimum":1,"maximum":10}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
||||||
@@ -117,11 +115,9 @@ pub const DOC_TOOL_FIND: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
||||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"maxResults":{"type":"integer","minimum":1,"maximum":30}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
||||||
name: "docs_search",
|
name: "docs_search",
|
||||||
display_name: "跨页文档搜索",
|
display_name: "跨页文档搜索",
|
||||||
@@ -130,8 +126,7 @@ pub const DOCS_TOOL_SEARCH: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["query"],"properties":{"query":{"type":"string"},"workspaceId":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":30},"includeDeleted":{"type":"boolean"},"pageId":{"type":"string"},"titleOnly":{"type":"boolean"},"exact":{"type":"boolean"},"includeOcr":{"type":"boolean"},"timeRange":{"type":"string"},"timeField":{"type":"string"},"customRangeFrom":{"type":"string"},"customRangeTo":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
||||||
@@ -142,8 +137,7 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
||||||
r#"{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string"},"maxChars":{"type":"integer","minimum":200,"maximum":20000},"includeContent":{"type":"boolean"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
||||||
@@ -154,8 +148,7 @@ pub const IMAGE_READ_TOOL: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","properties":{"assetId":{"type":"string"},"fileUrl":{"type":"string"},"attachmentRef":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
||||||
@@ -166,8 +159,7 @@ pub const DOC_TOOL_INSERT_BLOCKS: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
|
||||||
r#"{"type":"object","required":["blocks"],"properties":{"afterBlockId":{"type":"string"},"beforeBlockId":{"type":"string"},"blocks":{"type":"array","minItems":1,"maxItems":20}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
||||||
@@ -178,8 +170,7 @@ pub const DOC_TOOL_REPLACE_RANGE: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
|
||||||
r#"{"type":"object","required":["blockId","text"],"properties":{"blockId":{"type":"string"},"text":{"type":"string"},"mode":{"enum":["replace","append","prepend"]}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
||||||
@@ -190,8 +181,7 @@ pub const SLASH_RUN_TOOL: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","properties":{"text":{"type":"string"},"command":{"enum":["new_doc","rename_doc"]},"params":{"type":"object"},"reason":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
||||||
@@ -202,8 +192,7 @@ pub const MINDMAP_TOOL_GET: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
||||||
r#"{"type":"object","properties":{"maxNodes":{"type":"integer","minimum":10,"maximum":300}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
||||||
@@ -214,8 +203,7 @@ pub const MINDMAP_TOOL_GET_SUBTREE: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
|
||||||
r#"{"type":"object","required":["uid"],"properties":{"uid":{"type":"string"},"depth":{"type":"integer","minimum":0,"maximum":6},"maxNodes":{"type":"integer","minimum":5,"maximum":200}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
||||||
@@ -226,8 +214,7 @@ pub const MINDMAP_TOOL_PUT: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
||||||
r#"{"type":"object","required":["data"],"properties":{"data":{"type":"object"},"createOnly":{"type":"boolean"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
||||||
@@ -238,8 +225,7 @@ pub const MINDMAP_TOOL_APPLY_OPS: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
|
||||||
r#"{"type":"object","required":["ops"],"properties":{"ops":{"type":"array","minItems":1,"maxItems":80},"reason":{"type":"string"},"targetUid":{"type":"string"},"searchResults":{"type":"array"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
||||||
@@ -250,8 +236,7 @@ pub const MINDMAP_TOOL_EXPAND_NODE: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["targetUid"],"properties":{"targetUid":{"type":"string"},"instruction":{"type":"string"},"ops":{"type":"array","maxItems":80},"searchResults":{"type":"array"},"reason":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
||||||
@@ -262,8 +247,7 @@ pub const MINDMAP_TOOL_EMPTY_TRASH: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
|
pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
|
||||||
@@ -274,8 +258,7 @@ pub const MINDMAP_TOOL_OUTLINE_TO_MINDMAP: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Command,
|
invocation_kind: InvocationKind::Command,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
|
||||||
r#"{"type":"object","required":["rootTitle","pageLinkPattern","outline"],"properties":{"rootTitle":{"type":"string"},"pageLinkPattern":{"type":"string"},"outline":{"type":"array","items":{"type":"object","required":["title","level","page"],"properties":{"title":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":6},"page":{"type":"integer","minimum":1}}}}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
||||||
@@ -286,8 +269,7 @@ pub const BRIDGE_TOOL_REQUEST_GET: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["workspaceId","requestId"],"properties":{"workspaceId":{"type":"string"},"requestId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
||||||
@@ -298,8 +280,7 @@ pub const BRIDGE_TOOL_TRACE_GET: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["workspaceId","traceId"],"properties":{"workspaceId":{"type":"string"},"traceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
||||||
@@ -310,8 +291,7 @@ pub const BRIDGE_TOOL_COMMAND_GET: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Query,
|
invocation_kind: InvocationKind::Query,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["workspaceId","commandId"],"properties":{"workspaceId":{"type":"string"},"commandId":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
||||||
@@ -322,8 +302,7 @@ pub const REPLAY_TOOL_EVENTS: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Job,
|
invocation_kind: InvocationKind::Job,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
||||||
@@ -334,8 +313,7 @@ pub const INDEX_TOOL_REBUILD: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Job,
|
invocation_kind: InvocationKind::Job,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: true,
|
requires_confirmation: true,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["workspaceId"],"properties":{"workspaceId":{"type":"string"},"lastProcessedEventId":{"type":"string"},"lastProcessedAt":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
||||||
@@ -346,8 +324,7 @@ pub const ONLYOFFICE_TOOL_SESSION_RESOLVE: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Job,
|
invocation_kind: InvocationKind::Job,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["assetId"],"properties":{"assetId":{"type":"string"},"workspaceId":{"type":"string"},"documentId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
||||||
@@ -358,8 +335,7 @@ pub const ONLYOFFICE_TOOL_SIGN: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Job,
|
invocation_kind: InvocationKind::Job,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","properties":{"config":{"type":"object"},"secret":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
||||||
@@ -370,8 +346,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_PROXY: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Job,
|
invocation_kind: InvocationKind::Job,
|
||||||
effect: ToolEffect::Read,
|
effect: ToolEffect::Read,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["encodedUrl"],"properties":{"encodedUrl":{"type":"string"},"method":{"enum":["GET","HEAD"]},"range":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
||||||
@@ -382,8 +357,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_CALLBACK: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Job,
|
invocation_kind: InvocationKind::Job,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["assetId","status","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"documentId":{"type":"string"},"workspaceId":{"type":"string"},"userId":{"type":"string"},"sessionId":{"type":"string"},"status":{"type":"integer"},"url":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
||||||
@@ -394,8 +368,7 @@ pub const ONLYOFFICE_TOOL_PREPARE_FORCESAVE: ToolSpec = ToolSpec {
|
|||||||
invocation_kind: InvocationKind::Job,
|
invocation_kind: InvocationKind::Job,
|
||||||
effect: ToolEffect::Write,
|
effect: ToolEffect::Write,
|
||||||
requires_confirmation: false,
|
requires_confirmation: false,
|
||||||
input_schema_json:
|
input_schema_json: r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
|
||||||
r#"{"type":"object","required":["assetId","key","onlyofficeInternalUrl"],"properties":{"assetId":{"type":"string"},"key":{"type":"string"},"onlyofficeInternalUrl":{"type":"string"},"secret":{"type":"string"}}}"#,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||||
@@ -406,7 +379,6 @@ pub const DOC_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
|||||||
tool_names: &["doc_get", "doc_find"],
|
tool_names: &["doc_get", "doc_find"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
pub const DOCS_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
||||||
id: "toolset.docs_read",
|
id: "toolset.docs_read",
|
||||||
display_name: "跨页文档读取",
|
display_name: "跨页文档读取",
|
||||||
@@ -466,7 +438,11 @@ pub const OBSERVE_TOOLSET_READ: ToolSetSpec = ToolSetSpec {
|
|||||||
display_name: "统一观测",
|
display_name: "统一观测",
|
||||||
description: "按 request、trace、command 回查统一日志与事件视图。",
|
description: "按 request、trace、command 回查统一日志与事件视图。",
|
||||||
write_toolset: false,
|
write_toolset: false,
|
||||||
tool_names: &["bridge_request_get", "bridge_trace_get", "bridge_command_get"],
|
tool_names: &[
|
||||||
|
"bridge_request_get",
|
||||||
|
"bridge_trace_get",
|
||||||
|
"bridge_command_get",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const RECOVERY_TOOLSET_JOB: ToolSetSpec = ToolSetSpec {
|
pub const RECOVERY_TOOLSET_JOB: ToolSetSpec = ToolSetSpec {
|
||||||
|
|||||||
@@ -273,7 +273,9 @@ pub fn search_pages(
|
|||||||
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
|
IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.filter(|document| workspace_id.map_or(true, |workspace| document.workspace_id == workspace))
|
.filter(|document| {
|
||||||
|
workspace_id.map_or(true, |workspace| document.workspace_id == workspace)
|
||||||
|
})
|
||||||
.filter(|document| {
|
.filter(|document| {
|
||||||
document
|
document
|
||||||
.title
|
.title
|
||||||
@@ -376,7 +378,8 @@ pub fn evaluate_search_documents(
|
|||||||
request.custom_range_to.as_deref(),
|
request.custom_range_to.as_deref(),
|
||||||
),
|
),
|
||||||
_ => within_range(
|
_ => within_range(
|
||||||
document.updated_at
|
document
|
||||||
|
.updated_at
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(document.created_at.as_deref()),
|
.or(document.created_at.as_deref()),
|
||||||
boundary_iso.as_deref(),
|
boundary_iso.as_deref(),
|
||||||
@@ -386,7 +389,10 @@ pub fn evaluate_search_documents(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let eligible_doc_ids: HashSet<&str> = eligible_docs.iter().map(|document| document.id.as_str()).collect();
|
let eligible_doc_ids: HashSet<&str> = eligible_docs
|
||||||
|
.iter()
|
||||||
|
.map(|document| document.id.as_str())
|
||||||
|
.collect();
|
||||||
let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
|
let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|document| (document.id.as_str(), *document))
|
.map(|document| (document.id.as_str(), *document))
|
||||||
@@ -511,7 +517,10 @@ pub fn evaluate_search_documents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let file_name = asset.file_name.as_deref().unwrap_or("").trim();
|
let file_name = asset.file_name.as_deref().unwrap_or("").trim();
|
||||||
if !request.title_only && !file_name.is_empty() && file_name.to_lowercase().contains(&normalized_lower) {
|
if !request.title_only
|
||||||
|
&& !file_name.is_empty()
|
||||||
|
&& file_name.to_lowercase().contains(&normalized_lower)
|
||||||
|
{
|
||||||
upsert_match(
|
upsert_match(
|
||||||
&mut matches,
|
&mut matches,
|
||||||
&asset.document_id,
|
&asset.document_id,
|
||||||
@@ -552,7 +561,14 @@ pub fn evaluate_search_documents(
|
|||||||
score: 1.7,
|
score: 1.7,
|
||||||
match_field: SearchMatchField::Content,
|
match_field: SearchMatchField::Content,
|
||||||
snippet: build_snippet(
|
snippet: build_snippet(
|
||||||
&format!("附件:{}\n{ocr_text}", if file_name.is_empty() { asset.id.as_str() } else { file_name }),
|
&format!(
|
||||||
|
"附件:{}\n{ocr_text}",
|
||||||
|
if file_name.is_empty() {
|
||||||
|
asset.id.as_str()
|
||||||
|
} else {
|
||||||
|
file_name
|
||||||
|
}
|
||||||
|
),
|
||||||
normalized_query,
|
normalized_query,
|
||||||
),
|
),
|
||||||
has_ocr: true,
|
has_ocr: true,
|
||||||
@@ -798,7 +814,12 @@ fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_text(value: &str) -> String {
|
fn normalize_text(value: &str) -> String {
|
||||||
value.split_whitespace().collect::<Vec<_>>().join(" ").trim().to_string()
|
value
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_html(value: &str) -> String {
|
fn escape_html(value: &str) -> String {
|
||||||
@@ -1090,7 +1111,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(result.results.len(), 1);
|
assert_eq!(result.results.len(), 1);
|
||||||
assert_eq!(result.results[0].id, "page_1");
|
assert_eq!(result.results[0].id, "page_1");
|
||||||
assert!(result.results[0].snippet.contains("<mark>Rust</mark>") || result.results[0].snippet.contains("<mark>rust</mark>"));
|
assert!(
|
||||||
|
result.results[0].snippet.contains("<mark>Rust</mark>")
|
||||||
|
|| result.results[0].snippet.contains("<mark>rust</mark>")
|
||||||
|
);
|
||||||
assert_eq!(result.results[0].node_id.as_deref(), Some("page_1"));
|
assert_eq!(result.results[0].node_id.as_deref(), Some("page_1"));
|
||||||
assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1"));
|
assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1"));
|
||||||
assert_eq!(result.results[0].evidence.len(), 1);
|
assert_eq!(result.results[0].evidence.len(), 1);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ base64 = "0.22"
|
|||||||
bridge-runtime = { path = "../bridge-runtime" }
|
bridge-runtime = { path = "../bridge-runtime" }
|
||||||
clap = { version = "4.5.38", features = ["derive"] }
|
clap = { version = "4.5.38", features = ["derive"] }
|
||||||
core-protocol = { path = "../core-protocol" }
|
core-protocol = { path = "../core-protocol" }
|
||||||
|
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use bridge_runtime::{
|
|||||||
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
|
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
|
||||||
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
|
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
|
||||||
};
|
};
|
||||||
|
use clap::ValueEnum;
|
||||||
use core_protocol::{
|
use core_protocol::{
|
||||||
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
|
default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope,
|
||||||
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
|
CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind,
|
||||||
@@ -10,6 +11,11 @@ use core_protocol::{
|
|||||||
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
|
QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments,
|
||||||
SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
|
SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle,
|
||||||
};
|
};
|
||||||
|
use mnote_editor_core::{
|
||||||
|
apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock,
|
||||||
|
EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession,
|
||||||
|
VisibilitySnapshot,
|
||||||
|
};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -130,6 +136,80 @@ pub struct CliTransportPlan {
|
|||||||
pub args_json: Value,
|
pub args_json: Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorCliOutput {
|
||||||
|
pub ok: bool,
|
||||||
|
pub entrypoint: &'static str,
|
||||||
|
pub operation: &'static str,
|
||||||
|
pub block_count: usize,
|
||||||
|
pub output_markdown: String,
|
||||||
|
pub visible_block_ids: Vec<String>,
|
||||||
|
pub outline: Vec<EditorOutlineItem>,
|
||||||
|
pub audit: Vec<EditorAuditItem>,
|
||||||
|
pub change_report: EditorChangeReportOutput,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub undo_applied: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub redo_applied: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorOutlineItem {
|
||||||
|
pub block_id: String,
|
||||||
|
pub level: u8,
|
||||||
|
pub title: String,
|
||||||
|
pub depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorAuditItem {
|
||||||
|
pub command: String,
|
||||||
|
pub target_block_id: Option<String>,
|
||||||
|
pub before: Option<EditorBlockSnapshot>,
|
||||||
|
pub after: Option<EditorBlockSnapshot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorBlockSnapshot {
|
||||||
|
pub block_id: String,
|
||||||
|
pub block_type: String,
|
||||||
|
pub text: String,
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
pub indent: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EditorChangeReportOutput {
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_blocks: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_blocks: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub moved_blocks: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub removed_blocks: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||||
|
pub enum EditorInputKindArg {
|
||||||
|
PlainText,
|
||||||
|
Markdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||||
|
pub enum EditorAiScenarioArg {
|
||||||
|
MeetingNotesToTodos,
|
||||||
|
LongParagraphToTitle,
|
||||||
|
PageReorder,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct PageCreateArgs<'a> {
|
pub struct PageCreateArgs<'a> {
|
||||||
pub page_id: &'a str,
|
pub page_id: &'a str,
|
||||||
@@ -194,6 +274,8 @@ pub fn supported_command_surface() -> Vec<&'static str> {
|
|||||||
"search documents",
|
"search documents",
|
||||||
"search blocks",
|
"search blocks",
|
||||||
"sidebar dataset",
|
"sidebar dataset",
|
||||||
|
"editor markdown-roundtrip",
|
||||||
|
"editor session-demo",
|
||||||
"tool run",
|
"tool run",
|
||||||
];
|
];
|
||||||
for tool_name in default_tool_registry().tool_names() {
|
for tool_name in default_tool_registry().tool_names() {
|
||||||
@@ -223,6 +305,8 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
|
|||||||
"search.documents",
|
"search.documents",
|
||||||
"search.blocks",
|
"search.blocks",
|
||||||
"sidebar.dataset",
|
"sidebar.dataset",
|
||||||
|
"editor.markdown_roundtrip",
|
||||||
|
"editor.session_demo",
|
||||||
"tool.run",
|
"tool.run",
|
||||||
];
|
];
|
||||||
for tool_name in default_tool_registry().tool_names() {
|
for tool_name in default_tool_registry().tool_names() {
|
||||||
@@ -231,6 +315,179 @@ pub fn supported_json_contracts() -> Vec<&'static str> {
|
|||||||
contracts
|
contracts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn editor_markdown_roundtrip(markdown: &str) -> CliResult<EditorCliOutput> {
|
||||||
|
let document = import_markdown(markdown).map_err(map_editor_error)?;
|
||||||
|
let projection = VisibilitySnapshot::derive(&document);
|
||||||
|
Ok(build_editor_output(
|
||||||
|
"markdown_roundtrip",
|
||||||
|
document.blocks().len(),
|
||||||
|
export_markdown(&document),
|
||||||
|
&projection,
|
||||||
|
Vec::new(),
|
||||||
|
EditorChangeReportOutput::default(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn editor_ai_pipeline(
|
||||||
|
kind: EditorInputKind,
|
||||||
|
scenario: EditorAiScenario,
|
||||||
|
input: &str,
|
||||||
|
) -> CliResult<EditorCliOutput> {
|
||||||
|
let result = apply_ai_pipeline(EditorPipelineRequest {
|
||||||
|
kind,
|
||||||
|
scenario,
|
||||||
|
input: input.to_string(),
|
||||||
|
})
|
||||||
|
.map_err(map_editor_error)?;
|
||||||
|
let projection = VisibilitySnapshot::derive(&result.document);
|
||||||
|
Ok(build_editor_output(
|
||||||
|
"ai_pipeline",
|
||||||
|
result.document.blocks().len(),
|
||||||
|
result.markdown,
|
||||||
|
&projection,
|
||||||
|
result
|
||||||
|
.audit
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| EditorAuditItem {
|
||||||
|
command: item.command,
|
||||||
|
target_block_id: item.target_block_id,
|
||||||
|
before: item.before.map(|snapshot| EditorBlockSnapshot {
|
||||||
|
block_id: snapshot.block_id,
|
||||||
|
block_type: snapshot.block_type,
|
||||||
|
text: snapshot.text,
|
||||||
|
parent_id: snapshot.parent_id,
|
||||||
|
indent: snapshot.indent,
|
||||||
|
}),
|
||||||
|
after: item.after.map(|snapshot| EditorBlockSnapshot {
|
||||||
|
block_id: snapshot.block_id,
|
||||||
|
block_type: snapshot.block_type,
|
||||||
|
text: snapshot.text,
|
||||||
|
parent_id: snapshot.parent_id,
|
||||||
|
indent: snapshot.indent,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
EditorChangeReportOutput {
|
||||||
|
created_blocks: result.change_report.created_blocks,
|
||||||
|
updated_blocks: result.change_report.updated_blocks,
|
||||||
|
moved_blocks: result.change_report.moved_blocks,
|
||||||
|
removed_blocks: result.change_report.removed_blocks,
|
||||||
|
notes: result.change_report.notes,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn editor_session_demo(markdown: &str) -> CliResult<EditorCliOutput> {
|
||||||
|
let document = import_markdown(markdown).map_err(map_editor_error)?;
|
||||||
|
let bootstrap = if document.blocks().is_empty() {
|
||||||
|
mnote_editor_core::DocumentModel::new(vec![DocumentBlock::new(
|
||||||
|
"demo_root",
|
||||||
|
BlockType::Paragraph,
|
||||||
|
)
|
||||||
|
.with_text("demo root")])
|
||||||
|
} else {
|
||||||
|
document
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut session = EditorSession::new(bootstrap);
|
||||||
|
let primary_block_id = session
|
||||||
|
.document()
|
||||||
|
.blocks()
|
||||||
|
.first()
|
||||||
|
.map(|block| block.id.clone())
|
||||||
|
.ok_or_else(|| CliError::validation("session demo 缺少可编辑块"))?;
|
||||||
|
let original_text = session
|
||||||
|
.document()
|
||||||
|
.block(&primary_block_id)
|
||||||
|
.map(|block| block.content.text.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let next_text = if original_text.trim().is_empty() {
|
||||||
|
"demo edited".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{original_text} [edited]")
|
||||||
|
};
|
||||||
|
|
||||||
|
session
|
||||||
|
.apply_command(EditorCommand::ReplaceBlock {
|
||||||
|
block_id: primary_block_id.clone(),
|
||||||
|
text: next_text,
|
||||||
|
})
|
||||||
|
.map_err(map_editor_error)?;
|
||||||
|
session
|
||||||
|
.apply_command(EditorCommand::InsertBlockAfter {
|
||||||
|
after_block_id: Some(primary_block_id),
|
||||||
|
block: DocumentBlock::new("cli_demo_block", BlockType::Paragraph)
|
||||||
|
.with_text("CLI demo block"),
|
||||||
|
})
|
||||||
|
.map_err(map_editor_error)?;
|
||||||
|
|
||||||
|
let undo_applied = session.undo();
|
||||||
|
let redo_applied = session.redo();
|
||||||
|
let projection = VisibilitySnapshot::derive(session.document());
|
||||||
|
Ok(build_editor_output(
|
||||||
|
"session_demo",
|
||||||
|
session.document().blocks().len(),
|
||||||
|
export_markdown(session.document()),
|
||||||
|
&projection,
|
||||||
|
Vec::new(),
|
||||||
|
EditorChangeReportOutput::default(),
|
||||||
|
Some(undo_applied),
|
||||||
|
Some(redo_applied),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_editor_plain_output(output: &EditorCliOutput) -> String {
|
||||||
|
format!(
|
||||||
|
"entrypoint={} operation={} block_count={} visible_blocks={} audit_count={} updated_blocks={} undo_applied={} redo_applied={}",
|
||||||
|
output.entrypoint,
|
||||||
|
output.operation,
|
||||||
|
output.block_count,
|
||||||
|
output.visible_block_ids.join(","),
|
||||||
|
output.audit.len(),
|
||||||
|
output.change_report.updated_blocks.join(","),
|
||||||
|
output.undo_applied.unwrap_or(false),
|
||||||
|
output.redo_applied.unwrap_or(false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_editor_output(
|
||||||
|
operation: &'static str,
|
||||||
|
block_count: usize,
|
||||||
|
output_markdown: String,
|
||||||
|
projection: &VisibilitySnapshot,
|
||||||
|
audit: Vec<EditorAuditItem>,
|
||||||
|
change_report: EditorChangeReportOutput,
|
||||||
|
undo_applied: Option<bool>,
|
||||||
|
redo_applied: Option<bool>,
|
||||||
|
) -> EditorCliOutput {
|
||||||
|
EditorCliOutput {
|
||||||
|
ok: true,
|
||||||
|
entrypoint: "mnote-cli",
|
||||||
|
operation,
|
||||||
|
block_count,
|
||||||
|
output_markdown,
|
||||||
|
visible_block_ids: projection.visible_block_ids.clone(),
|
||||||
|
outline: projection
|
||||||
|
.outline
|
||||||
|
.iter()
|
||||||
|
.map(|entry| EditorOutlineItem {
|
||||||
|
block_id: entry.block_id.clone(),
|
||||||
|
level: entry.level,
|
||||||
|
title: entry.title.clone(),
|
||||||
|
depth: entry.depth,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
audit,
|
||||||
|
change_report,
|
||||||
|
undo_applied,
|
||||||
|
redo_applied,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_plain_output(output: &CliJsonOutput) -> String {
|
pub fn render_plain_output(output: &CliJsonOutput) -> String {
|
||||||
match &output.operation {
|
match &output.operation {
|
||||||
CliOperationOutput::Command {
|
CliOperationOutput::Command {
|
||||||
@@ -2359,6 +2616,10 @@ fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
|
|||||||
CliError::validation(error.message)
|
CliError::validation(error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError {
|
||||||
|
CliError::validation(error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -2374,9 +2635,121 @@ mod tests {
|
|||||||
assert!(commands.contains(&"mindmap get"));
|
assert!(commands.contains(&"mindmap get"));
|
||||||
assert!(commands.contains(&"search documents"));
|
assert!(commands.contains(&"search documents"));
|
||||||
assert!(commands.contains(&"sidebar dataset"));
|
assert!(commands.contains(&"sidebar dataset"));
|
||||||
|
assert!(commands.contains(&"editor markdown-roundtrip"));
|
||||||
|
assert!(commands.contains(&"editor session-demo"));
|
||||||
assert!(commands.contains(&"tool run"));
|
assert!(commands.contains(&"tool run"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_markdown_roundtrip_returns_outline_and_markdown() {
|
||||||
|
let output =
|
||||||
|
editor_markdown_roundtrip("# 标题\n普通段落").expect("roundtrip should succeed");
|
||||||
|
|
||||||
|
assert_eq!(output.operation, "markdown_roundtrip");
|
||||||
|
assert_eq!(output.block_count, 2);
|
||||||
|
assert!(output.output_markdown.contains("# 标题"));
|
||||||
|
assert_eq!(
|
||||||
|
output.visible_block_ids,
|
||||||
|
vec!["imported_1".to_string(), "imported_2".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(output.outline.len(), 1);
|
||||||
|
assert_eq!(output.outline[0].title, "标题");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_session_demo_applies_insert_and_redo() {
|
||||||
|
let output = editor_session_demo("原始段落").expect("session demo should succeed");
|
||||||
|
|
||||||
|
assert_eq!(output.operation, "session_demo");
|
||||||
|
assert_eq!(output.block_count, 2);
|
||||||
|
assert_eq!(output.undo_applied, Some(true));
|
||||||
|
assert_eq!(output.redo_applied, Some(true));
|
||||||
|
assert!(output.output_markdown.contains("原始段落 [edited]"));
|
||||||
|
assert!(output.output_markdown.contains("CLI demo block"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_ai_pipeline_exposes_audit_and_change_report() {
|
||||||
|
let output = editor_ai_pipeline(
|
||||||
|
EditorInputKind::PlainText,
|
||||||
|
EditorAiScenario::MeetingNotesToTodos,
|
||||||
|
"待办:整理会议纪要\n普通段落",
|
||||||
|
)
|
||||||
|
.expect("ai pipeline should succeed");
|
||||||
|
|
||||||
|
assert_eq!(output.operation, "ai_pipeline");
|
||||||
|
assert_eq!(output.audit.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
output.change_report.updated_blocks,
|
||||||
|
vec!["imported_1".to_string()]
|
||||||
|
);
|
||||||
|
assert!(output.output_markdown.contains("待办:整理会议纪要"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_ai_pipeline_reorders_pages() {
|
||||||
|
let output = editor_ai_pipeline(
|
||||||
|
EditorInputKind::PlainText,
|
||||||
|
EditorAiScenario::PageReorder,
|
||||||
|
"第一页\n\n第二页",
|
||||||
|
)
|
||||||
|
.expect("ai pipeline should succeed");
|
||||||
|
|
||||||
|
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
|
||||||
|
assert_eq!(
|
||||||
|
output.change_report.moved_blocks,
|
||||||
|
vec!["imported_1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_regression_meeting_notes_to_todos() {
|
||||||
|
let output = editor_ai_pipeline(
|
||||||
|
EditorInputKind::PlainText,
|
||||||
|
EditorAiScenario::MeetingNotesToTodos,
|
||||||
|
"待办:整理会议纪要\n普通段落",
|
||||||
|
)
|
||||||
|
.expect("ai regression meeting notes should succeed");
|
||||||
|
|
||||||
|
assert!(output.output_markdown.contains("- [ ] 待办:整理会议纪要"));
|
||||||
|
assert_eq!(
|
||||||
|
output.change_report.updated_blocks,
|
||||||
|
vec!["imported_1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_regression_long_paragraph_to_title() {
|
||||||
|
let output = editor_ai_pipeline(
|
||||||
|
EditorInputKind::PlainText,
|
||||||
|
EditorAiScenario::LongParagraphToTitle,
|
||||||
|
"这是一个很长的段落,用来验证 AI 回归样例会把首块提炼成标题,并保留后续结构化内容。",
|
||||||
|
)
|
||||||
|
.expect("ai regression long paragraph should succeed");
|
||||||
|
|
||||||
|
assert!(output.output_markdown.starts_with("# 这是一个很长的段落"));
|
||||||
|
assert_eq!(
|
||||||
|
output.change_report.updated_blocks,
|
||||||
|
vec!["imported_1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ai_regression_page_reorder() {
|
||||||
|
let output = editor_ai_pipeline(
|
||||||
|
EditorInputKind::PlainText,
|
||||||
|
EditorAiScenario::PageReorder,
|
||||||
|
"第一页\n\n第二页",
|
||||||
|
)
|
||||||
|
.expect("ai regression reorder should succeed");
|
||||||
|
|
||||||
|
assert_eq!(output.output_markdown.lines().next(), Some("第二页"));
|
||||||
|
assert_eq!(
|
||||||
|
output.change_report.moved_blocks,
|
||||||
|
vec!["imported_1".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn page_save_json_contract_uses_documents_save() {
|
fn page_save_json_contract_uses_documents_save() {
|
||||||
let output = plan_page_save(
|
let output = plan_page_save(
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use clap::{Args, Parser, Subcommand, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
use core_protocol::{InvocationKind, ToolExecutionMode};
|
use core_protocol::{InvocationKind, ToolExecutionMode};
|
||||||
use mnote_cli::{
|
use mnote_cli::{
|
||||||
execute_output, plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch,
|
editor_ai_pipeline, editor_markdown_roundtrip, editor_session_demo, execute_output,
|
||||||
plan_mindmap_get, plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete,
|
plan_block_embed, plan_block_insert, plan_block_move, plan_block_patch, plan_mindmap_get,
|
||||||
plan_page_get, plan_page_move, plan_page_restore, plan_page_save, plan_page_title,
|
plan_mindmap_op, plan_mindmap_put, plan_page_create, plan_page_delete, plan_page_get,
|
||||||
plan_search_blocks, plan_search_documents, plan_sidebar_dataset, plan_tool_run,
|
plan_page_move, plan_page_restore, plan_page_save, plan_page_title, plan_search_blocks,
|
||||||
|
plan_search_documents, plan_sidebar_dataset, plan_tool_run, render_editor_plain_output,
|
||||||
render_plain_output, BlockEmbedArgs, BlockMoveArgs, CliContext, CliError, CliJsonOutput,
|
render_plain_output, BlockEmbedArgs, BlockMoveArgs, CliContext, CliError, CliJsonOutput,
|
||||||
PageCreateArgs, PageDeleteArgs, PageMoveArgs, PageRestoreArgs,
|
EditorAiScenarioArg, EditorCliOutput, EditorInputKindArg, PageCreateArgs, PageDeleteArgs,
|
||||||
|
PageMoveArgs, PageRestoreArgs,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -57,6 +59,7 @@ enum Commands {
|
|||||||
Mindmap(MindmapCommand),
|
Mindmap(MindmapCommand),
|
||||||
Search(SearchCommand),
|
Search(SearchCommand),
|
||||||
Sidebar(SidebarCommand),
|
Sidebar(SidebarCommand),
|
||||||
|
Editor(EditorCliCommand),
|
||||||
Tool(ToolCommand),
|
Tool(ToolCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +373,43 @@ struct SidebarDatasetArgs {
|
|||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct EditorCliCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: EditorAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum EditorAction {
|
||||||
|
MarkdownRoundtrip(EditorMarkdownRoundtripArgs),
|
||||||
|
SessionDemo(EditorSessionDemoArgs),
|
||||||
|
AiPipeline(EditorAiPipelineArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct EditorMarkdownRoundtripArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct EditorSessionDemoArgs {
|
||||||
|
#[arg(long)]
|
||||||
|
markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct EditorAiPipelineArgs {
|
||||||
|
#[arg(long, value_enum, default_value_t = EditorInputKindArg::PlainText)]
|
||||||
|
kind: EditorInputKindArg,
|
||||||
|
|
||||||
|
#[arg(long, value_enum, default_value_t = EditorAiScenarioArg::MeetingNotesToTodos)]
|
||||||
|
scenario: EditorAiScenarioArg,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
input: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
struct ToolCommand {
|
struct ToolCommand {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -422,6 +462,11 @@ fn main() {
|
|||||||
dry_run: cli.global.dry_run,
|
dry_run: cli.global.dry_run,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum CommandOutcome {
|
||||||
|
Bridge(CliJsonOutput),
|
||||||
|
Editor(EditorCliOutput),
|
||||||
|
}
|
||||||
|
|
||||||
let result = match cli.command {
|
let result = match cli.command {
|
||||||
Commands::Page(command) => match command.action {
|
Commands::Page(command) => match command.action {
|
||||||
PageAction::Get(args) => {
|
PageAction::Get(args) => {
|
||||||
@@ -475,7 +520,8 @@ fn main() {
|
|||||||
workspace_id: args.workspace_id.as_deref(),
|
workspace_id: args.workspace_id.as_deref(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
}
|
||||||
|
.map(CommandOutcome::Bridge),
|
||||||
Commands::Block(command) => match command.action {
|
Commands::Block(command) => match command.action {
|
||||||
BlockAction::Insert(args) => plan_block_insert(
|
BlockAction::Insert(args) => plan_block_insert(
|
||||||
&context,
|
&context,
|
||||||
@@ -512,7 +558,8 @@ fn main() {
|
|||||||
target_block_id: args.target_block_id.as_deref(),
|
target_block_id: args.target_block_id.as_deref(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
}
|
||||||
|
.map(CommandOutcome::Bridge),
|
||||||
Commands::Mindmap(command) => match command.action {
|
Commands::Mindmap(command) => match command.action {
|
||||||
MindmapAction::Get(args) => plan_mindmap_get(
|
MindmapAction::Get(args) => plan_mindmap_get(
|
||||||
&context,
|
&context,
|
||||||
@@ -535,7 +582,8 @@ fn main() {
|
|||||||
&args.mindmap_id,
|
&args.mindmap_id,
|
||||||
&args.ops_json,
|
&args.ops_json,
|
||||||
),
|
),
|
||||||
},
|
}
|
||||||
|
.map(CommandOutcome::Bridge),
|
||||||
Commands::Search(command) => match command.action {
|
Commands::Search(command) => match command.action {
|
||||||
SearchAction::Documents(args) => plan_search_documents(
|
SearchAction::Documents(args) => plan_search_documents(
|
||||||
&context,
|
&context,
|
||||||
@@ -551,10 +599,22 @@ fn main() {
|
|||||||
args.limit,
|
args.limit,
|
||||||
args.cursor.as_deref(),
|
args.cursor.as_deref(),
|
||||||
),
|
),
|
||||||
},
|
}
|
||||||
|
.map(CommandOutcome::Bridge),
|
||||||
Commands::Sidebar(command) => match command.action {
|
Commands::Sidebar(command) => match command.action {
|
||||||
SidebarAction::Dataset(args) => plan_sidebar_dataset(&context, &args.workspace_id),
|
SidebarAction::Dataset(args) => plan_sidebar_dataset(&context, &args.workspace_id),
|
||||||
},
|
}
|
||||||
|
.map(CommandOutcome::Bridge),
|
||||||
|
Commands::Editor(command) => match command.action {
|
||||||
|
EditorAction::MarkdownRoundtrip(args) => editor_markdown_roundtrip(&args.markdown),
|
||||||
|
EditorAction::SessionDemo(args) => editor_session_demo(&args.markdown),
|
||||||
|
EditorAction::AiPipeline(args) => editor_ai_pipeline(
|
||||||
|
to_editor_input_kind(args.kind),
|
||||||
|
to_editor_ai_scenario(args.scenario),
|
||||||
|
&args.input,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
.map(CommandOutcome::Editor),
|
||||||
Commands::Tool(command) => match command.action {
|
Commands::Tool(command) => match command.action {
|
||||||
ToolAction::Run(args) => plan_tool_run(
|
ToolAction::Run(args) => plan_tool_run(
|
||||||
&context,
|
&context,
|
||||||
@@ -563,18 +623,23 @@ fn main() {
|
|||||||
to_execution_mode(args.mode),
|
to_execution_mode(args.mode),
|
||||||
&args.args_json,
|
&args.args_json,
|
||||||
),
|
),
|
||||||
},
|
|
||||||
}
|
|
||||||
.and_then(|output| {
|
|
||||||
if cli.global.execute {
|
|
||||||
execute_output(&output, &context)
|
|
||||||
} else {
|
|
||||||
Ok(output)
|
|
||||||
}
|
}
|
||||||
|
.map(CommandOutcome::Bridge),
|
||||||
|
}
|
||||||
|
.and_then(|output| match output {
|
||||||
|
CommandOutcome::Bridge(output) => {
|
||||||
|
if cli.global.execute {
|
||||||
|
execute_output(&output, &context).map(CommandOutcome::Bridge)
|
||||||
|
} else {
|
||||||
|
Ok(CommandOutcome::Bridge(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandOutcome::Editor(output) => Ok(CommandOutcome::Editor(output)),
|
||||||
});
|
});
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(output) => emit_success(&output, cli.global.json),
|
Ok(CommandOutcome::Bridge(output)) => emit_success(&output, cli.global.json),
|
||||||
|
Ok(CommandOutcome::Editor(output)) => emit_editor_success(&output, cli.global.json),
|
||||||
Err(error) => emit_error(&error, cli.global.json),
|
Err(error) => emit_error(&error, cli.global.json),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -590,6 +655,17 @@ fn emit_success(output: &CliJsonOutput, json_mode: bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_editor_success(output: &EditorCliOutput, json_mode: bool) {
|
||||||
|
if json_mode {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(output).expect("editor CLI JSON 输出必须可序列化")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("{}", render_editor_plain_output(output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn emit_error(error: &CliError, json_mode: bool) -> ! {
|
fn emit_error(error: &CliError, json_mode: bool) -> ! {
|
||||||
if json_mode {
|
if json_mode {
|
||||||
println!(
|
println!(
|
||||||
@@ -625,3 +701,22 @@ fn to_execution_mode(mode: ToolModeArg) -> ToolExecutionMode {
|
|||||||
ToolModeArg::ExplainPlan => ToolExecutionMode::ExplainPlan,
|
ToolModeArg::ExplainPlan => ToolExecutionMode::ExplainPlan,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn to_editor_input_kind(kind: EditorInputKindArg) -> mnote_editor_core::EditorInputKind {
|
||||||
|
match kind {
|
||||||
|
EditorInputKindArg::PlainText => mnote_editor_core::EditorInputKind::PlainText,
|
||||||
|
EditorInputKindArg::Markdown => mnote_editor_core::EditorInputKind::Markdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_editor_ai_scenario(scenario: EditorAiScenarioArg) -> mnote_editor_core::EditorAiScenario {
|
||||||
|
match scenario {
|
||||||
|
EditorAiScenarioArg::MeetingNotesToTodos => {
|
||||||
|
mnote_editor_core::EditorAiScenario::MeetingNotesToTodos
|
||||||
|
}
|
||||||
|
EditorAiScenarioArg::LongParagraphToTitle => {
|
||||||
|
mnote_editor_core::EditorAiScenario::LongParagraphToTitle
|
||||||
|
}
|
||||||
|
EditorAiScenarioArg::PageReorder => mnote_editor_core::EditorAiScenario::PageReorder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }
|
core-protocol = { path = "../core-protocol" }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
leptos = { version = "0.8.14", default-features = false, features = ["ssr"] }
|
||||||
|
mnote-editor-core = { path = "../mnote-editor-core" }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
storage-convex-bridge = { path = "../storage-convex-bridge" }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
use crate::app::AppState;
|
||||||
|
use crate::context::RequestContext;
|
||||||
|
use crate::error::WebError;
|
||||||
|
use crate::routes::command_support::execute_runtime_command_via_convex;
|
||||||
|
use crate::routes::query_support::{
|
||||||
|
execute_runtime_query_via_convex, fetch_documents_meta_via_convex,
|
||||||
|
resolve_effective_workspace_id,
|
||||||
|
};
|
||||||
|
use axum::extract::{Extension, Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::Json;
|
||||||
|
use bridge_runtime::RuntimeQueryEnvelopeWire;
|
||||||
|
use bridge_runtime::{
|
||||||
|
RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::fs;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentContentQuery {
|
||||||
|
pub document_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentMetaQuery {
|
||||||
|
pub document_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DocumentSaveRequest {
|
||||||
|
pub document_id: String,
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
pub revision: Option<u64>,
|
||||||
|
pub conflict_detection_key: Option<String>,
|
||||||
|
pub content: Value,
|
||||||
|
pub snapshot_captured_at: Option<String>,
|
||||||
|
pub block_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL";
|
||||||
|
|
||||||
|
fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, Json<Value>) {
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"ok": true,
|
||||||
|
"requestId": context.trace.request_id,
|
||||||
|
"traceId": context.trace.trace_id,
|
||||||
|
"result": result,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||||
|
if let Ok(value) = std::env::var(key) {
|
||||||
|
let trimmed = value.trim().trim_matches('"').to_string();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../..")
|
||||||
|
.join(".env.all");
|
||||||
|
let content = fs::read_to_string(root).ok()?;
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim_end_matches('\r');
|
||||||
|
if line.starts_with('#') || line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((k, v)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if k.trim() != key {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = v.trim().trim_matches('"').to_string();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_documents_base_url() -> String {
|
||||||
|
read_env_or_dotenv(NEXT_DOCUMENTS_BASE_URL_ENV)
|
||||||
|
.unwrap_or_else(|| "http://127.0.0.1:3000".into())
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_proxy_via_next(context: &RequestContext) -> bool {
|
||||||
|
context
|
||||||
|
.auth
|
||||||
|
.cookie_header
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_next_proxy_headers(
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
) -> reqwest::header::HeaderMap {
|
||||||
|
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||||
|
|
||||||
|
fn insert(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
||||||
|
let Ok(header_name) = HeaderName::from_lowercase(name.as_bytes()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(header_value) = HeaderValue::from_str(value) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
if let Some(cookie) = context.auth.cookie_header.as_deref() {
|
||||||
|
insert(&mut headers, "cookie", cookie);
|
||||||
|
}
|
||||||
|
if let Some(authorization) = context.auth.authorization.as_deref() {
|
||||||
|
insert(&mut headers, "authorization", authorization);
|
||||||
|
}
|
||||||
|
insert(&mut headers, "x-request-id", &context.trace.request_id);
|
||||||
|
insert(&mut headers, "x-trace-id", &context.trace.trace_id);
|
||||||
|
insert(
|
||||||
|
&mut headers,
|
||||||
|
"x-mnote-source-channel",
|
||||||
|
&context.source.channel,
|
||||||
|
);
|
||||||
|
insert(
|
||||||
|
&mut headers,
|
||||||
|
"x-mnote-source-client",
|
||||||
|
&context.source.client,
|
||||||
|
);
|
||||||
|
insert(&mut headers, "x-mnote-actor-id", &context.auth.actor_id);
|
||||||
|
insert(&mut headers, "x-mnote-actor-type", &context.auth.actor_type);
|
||||||
|
if let Some(session_id) = context.auth.session_id.as_deref() {
|
||||||
|
insert(&mut headers, "x-mnote-session-id", session_id);
|
||||||
|
}
|
||||||
|
if let Some(workspace_id) = effective_workspace_id {
|
||||||
|
insert(&mut headers, "x-mnote-workspace-id", workspace_id);
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_next_documents_request(
|
||||||
|
context: &RequestContext,
|
||||||
|
request: reqwest::RequestBuilder,
|
||||||
|
phase: &'static str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let response = request.send().await.map_err(|error| {
|
||||||
|
let base = if error.is_timeout() {
|
||||||
|
WebError::gateway_timeout_code(
|
||||||
|
"next_proxy_timeout",
|
||||||
|
format!("Next compat 请求超时: {error}"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
WebError::service_unavailable_code(
|
||||||
|
"next_proxy_unavailable",
|
||||||
|
format!("Next compat 请求失败: {error}"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
base.with_context(context)
|
||||||
|
.with_header("x-error-phase", phase)
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.map_err(|error| {
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"next_proxy_bad_response",
|
||||||
|
format!("Next compat 响应读取失败: {error}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", phase)
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let payload = serde_json::from_str::<Value>(&text).map_err(|_| {
|
||||||
|
let snippet: String = text.chars().take(180).collect();
|
||||||
|
WebError::bad_gateway_code(
|
||||||
|
"next_proxy_bad_response",
|
||||||
|
format!("Next compat 返回了非 JSON 内容: {snippet}"),
|
||||||
|
)
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", phase)
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
.with_header("x-upstream-status", status.as_u16().to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let message = payload
|
||||||
|
.get("error")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| payload.get("message").and_then(Value::as_str))
|
||||||
|
.unwrap_or("Next compat 文档接口请求失败");
|
||||||
|
let web_error = match status {
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN => {
|
||||||
|
WebError::new(StatusCode::UNAUTHORIZED, "next_proxy_unauthorized", message)
|
||||||
|
}
|
||||||
|
reqwest::StatusCode::NOT_FOUND => {
|
||||||
|
WebError::new(StatusCode::NOT_FOUND, "next_proxy_not_found", message)
|
||||||
|
}
|
||||||
|
_ => WebError::bad_gateway_code("next_proxy_error", message),
|
||||||
|
};
|
||||||
|
return Err(web_error
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", phase)
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
.with_header("x-upstream-status", status.as_u16().to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_next_documents_meta(
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
document_id: &str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let base_url = next_documents_base_url();
|
||||||
|
let mut url =
|
||||||
|
reqwest::Url::parse(&format!("{base_url}/api/documents/meta")).map_err(|error| {
|
||||||
|
WebError::internal(format!("Next compat meta URL 非法: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "next_proxy_meta_url")
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
})?;
|
||||||
|
url.query_pairs_mut().append_pair("documentId", document_id);
|
||||||
|
if let Some(workspace_id) = effective_workspace_id {
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("workspaceId", workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "next_proxy_meta_client")
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let payload = send_next_documents_request(
|
||||||
|
context,
|
||||||
|
client
|
||||||
|
.get(url)
|
||||||
|
.headers(build_next_proxy_headers(context, effective_workspace_id)),
|
||||||
|
"next_proxy_meta",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(payload.get("doc").cloned().unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_next_documents_content(
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
document_id: &str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let base_url = next_documents_base_url();
|
||||||
|
let mut url =
|
||||||
|
reqwest::Url::parse(&format!("{base_url}/api/documents/content")).map_err(|error| {
|
||||||
|
WebError::internal(format!("Next compat content URL 非法: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "next_proxy_content_url")
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
})?;
|
||||||
|
url.query_pairs_mut().append_pair("documentId", document_id);
|
||||||
|
if let Some(workspace_id) = effective_workspace_id {
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("workspaceId", workspace_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "next_proxy_content_client")
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let payload = send_next_documents_request(
|
||||||
|
context,
|
||||||
|
client
|
||||||
|
.get(url)
|
||||||
|
.headers(build_next_proxy_headers(context, effective_workspace_id)),
|
||||||
|
"next_proxy_content",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"content": payload.get("content").cloned().unwrap_or(Value::Null),
|
||||||
|
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
|
||||||
|
"conflictDetectionKey": payload.get("conflictDetectionKey").cloned().unwrap_or(Value::Null),
|
||||||
|
"pageSubtree": payload.get("pageSubtree").cloned().unwrap_or(Value::Null),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_next_documents_save(
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
body: &DocumentSaveRequest,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let base_url = next_documents_base_url();
|
||||||
|
let url = reqwest::Url::parse(&format!("{base_url}/api/documents/save")).map_err(|error| {
|
||||||
|
WebError::internal(format!("Next compat save URL 非法: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "next_proxy_save_url")
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(20))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
WebError::internal(format!("Next compat HTTP 客户端创建失败: {error}"))
|
||||||
|
.with_context(context)
|
||||||
|
.with_header("x-error-phase", "next_proxy_save_client")
|
||||||
|
.with_header("x-upstream-service", "next")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let payload = send_next_documents_request(
|
||||||
|
context,
|
||||||
|
client
|
||||||
|
.post(url)
|
||||||
|
.headers(build_next_proxy_headers(context, effective_workspace_id))
|
||||||
|
.json(&json!({
|
||||||
|
"documentId": body.document_id,
|
||||||
|
"workspaceId": effective_workspace_id,
|
||||||
|
"revision": body.revision,
|
||||||
|
"conflictDetectionKey": body.conflict_detection_key,
|
||||||
|
"content": body.content,
|
||||||
|
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||||
|
"blockCount": body.block_count,
|
||||||
|
})),
|
||||||
|
"next_proxy_save",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"revision": payload.get("revision").cloned().unwrap_or(Value::Null),
|
||||||
|
"conflictDetectionKey": payload
|
||||||
|
.get("conflictDetectionKey")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
"ok": payload.get("ok").cloned().unwrap_or(Value::Bool(true)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn meta(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<DocumentMetaQuery>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||||
|
let document_id = query.document_id.trim();
|
||||||
|
if document_id.is_empty() {
|
||||||
|
return Err(
|
||||||
|
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||||
|
.with_context(&context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let effective_workspace_id =
|
||||||
|
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
|
||||||
|
if should_proxy_via_next(&context) {
|
||||||
|
let result =
|
||||||
|
proxy_next_documents_meta(&context, effective_workspace_id.as_deref(), document_id)
|
||||||
|
.await?;
|
||||||
|
return Ok(ok_response(&context, result));
|
||||||
|
}
|
||||||
|
let result = fetch_documents_meta_via_convex(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
effective_workspace_id.as_deref(),
|
||||||
|
document_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(ok_response(&context, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn content(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Query(query): Query<DocumentContentQuery>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||||
|
let document_id = query.document_id.trim();
|
||||||
|
if document_id.is_empty() {
|
||||||
|
return Err(
|
||||||
|
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||||
|
.with_context(&context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let effective_workspace_id =
|
||||||
|
resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), false)?;
|
||||||
|
if should_proxy_via_next(&context) {
|
||||||
|
let result =
|
||||||
|
proxy_next_documents_content(&context, effective_workspace_id.as_deref(), document_id)
|
||||||
|
.await?;
|
||||||
|
return Ok(ok_response(&context, result));
|
||||||
|
}
|
||||||
|
let result = execute_runtime_query_via_convex(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
effective_workspace_id.as_deref(),
|
||||||
|
RuntimeQueryEnvelopeWire {
|
||||||
|
name: "documents.content.get".into(),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": document_id,
|
||||||
|
"workspaceId": effective_workspace_id,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(ok_response(&context, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(context): Extension<RequestContext>,
|
||||||
|
Json(body): Json<DocumentSaveRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||||
|
let document_id = body.document_id.trim();
|
||||||
|
if document_id.is_empty() {
|
||||||
|
return Err(
|
||||||
|
WebError::bad_request_code("document_id_required", "缺少有效 documentId")
|
||||||
|
.with_context(&context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let effective_workspace_id =
|
||||||
|
resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?;
|
||||||
|
if should_proxy_via_next(&context) {
|
||||||
|
let result =
|
||||||
|
proxy_next_documents_save(&context, effective_workspace_id.as_deref(), &body).await?;
|
||||||
|
return Ok(ok_response(&context, result));
|
||||||
|
}
|
||||||
|
let command = RuntimeCommandEnvelopeWire {
|
||||||
|
name: "documents.save".into(),
|
||||||
|
command_id: format!("document_save_{}", context.trace.request_id),
|
||||||
|
idempotency_key: context.source.idempotency_key.clone(),
|
||||||
|
actor: RuntimeActorWire {
|
||||||
|
actor_type: context.auth.actor_type.clone(),
|
||||||
|
actor_id: context.auth.actor_id.clone(),
|
||||||
|
session_id: context.auth.session_id.clone(),
|
||||||
|
},
|
||||||
|
source: RuntimeSourceWire {
|
||||||
|
channel: context.source.channel.clone(),
|
||||||
|
client: context.source.client.clone(),
|
||||||
|
},
|
||||||
|
target: Some(RuntimeTargetWire {
|
||||||
|
workspace_id: effective_workspace_id.clone(),
|
||||||
|
page_id: Some(document_id.to_string()),
|
||||||
|
block_id: None,
|
||||||
|
}),
|
||||||
|
payload: json!({
|
||||||
|
"documentId": document_id,
|
||||||
|
"workspaceId": effective_workspace_id,
|
||||||
|
"revision": body.revision,
|
||||||
|
"conflictDetectionKey": body.conflict_detection_key,
|
||||||
|
"content": body.content,
|
||||||
|
"snapshotCapturedAt": body.snapshot_captured_at,
|
||||||
|
"blockCount": body.block_count,
|
||||||
|
}),
|
||||||
|
reason: Some("mnote-web human editor save".into()),
|
||||||
|
refs: vec!["mnote-web-editor-runtime".into()],
|
||||||
|
dry_run: false,
|
||||||
|
validate_only: false,
|
||||||
|
};
|
||||||
|
let result = execute_runtime_command_via_convex(
|
||||||
|
state.config(),
|
||||||
|
&context,
|
||||||
|
effective_workspace_id.as_deref(),
|
||||||
|
command,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(ok_response(&context, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::app::{build_app, AppConfig, AppState};
|
||||||
|
use axum::body::{to_bytes, Body};
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use serde_json::Value;
|
||||||
|
use tower::util::ServiceExt;
|
||||||
|
|
||||||
|
fn app() -> axum::Router {
|
||||||
|
build_app(AppState::new(AppConfig {
|
||||||
|
service_name: "mnote-web".into(),
|
||||||
|
service_version: "0.1.0".into(),
|
||||||
|
bind_addr: "127.0.0.1:0".into(),
|
||||||
|
hermes_base_path: "/api/hermes".into(),
|
||||||
|
compat_next_base_path: "/api/compat/next".into(),
|
||||||
|
convex_url: None,
|
||||||
|
convex_admin_key: None,
|
||||||
|
allow_dev_fixtures: true,
|
||||||
|
query_fixtures_json: Some(
|
||||||
|
r#"{
|
||||||
|
"documents:getMeta": {
|
||||||
|
"id": "doc_1",
|
||||||
|
"workspace_id": "ws_demo",
|
||||||
|
"title": "服务端页面",
|
||||||
|
"updated_at": "2026-04-18T09:30:00Z",
|
||||||
|
"can_edit": true,
|
||||||
|
"disable_download": false,
|
||||||
|
"disable_copy": false,
|
||||||
|
"wide_layout": false,
|
||||||
|
"use_small_text": false,
|
||||||
|
"show_heading_numbers": true,
|
||||||
|
"show_toc": true,
|
||||||
|
"show_structure": true,
|
||||||
|
"protect_editing": false,
|
||||||
|
"show_word_count": true,
|
||||||
|
"collapse_backlinks": false,
|
||||||
|
"page_font": "default",
|
||||||
|
"layout_density": "normal",
|
||||||
|
"hide_child_pages": false,
|
||||||
|
"show_block_ref_count": true,
|
||||||
|
"embed_default_block_id": "heading_1",
|
||||||
|
"word_count": 42,
|
||||||
|
"character_count": 128,
|
||||||
|
"block_count": 3,
|
||||||
|
"todo_total": 1,
|
||||||
|
"todo_done": 0
|
||||||
|
},
|
||||||
|
"documents:getContent": {
|
||||||
|
"title": "服务端页面",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"id": "heading_1",
|
||||||
|
"type": "heading",
|
||||||
|
"props": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "章节一" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"revision": 7,
|
||||||
|
"conflict_detection_key": "doc_1:7"
|
||||||
|
}
|
||||||
|
}"#
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
mutation_fixtures_json: Some(
|
||||||
|
r#"{
|
||||||
|
"documents:updateContent": {
|
||||||
|
"ok": true,
|
||||||
|
"updated_at": "2026-04-18T09:45:00Z",
|
||||||
|
"revision": 8,
|
||||||
|
"conflict_detection_key": "doc_1:8"
|
||||||
|
}
|
||||||
|
}"#
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
dev_user_id: "dev-user".into(),
|
||||||
|
dev_user_name: "开发用户".into(),
|
||||||
|
dev_user_email: "dev@mnote.local".into(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn documents_meta_route_returns_document_metadata() {
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/documents/meta?documentId=doc_1&workspaceId=ws_demo")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
assert_eq!(payload["result"]["id"], "doc_1");
|
||||||
|
assert_eq!(payload["result"]["workspace_id"], "ws_demo");
|
||||||
|
assert_eq!(payload["result"]["title"], "服务端页面");
|
||||||
|
assert_eq!(payload["result"]["show_structure"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn documents_content_route_returns_page_subtree() {
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/documents/content?documentId=doc_1")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
assert_eq!(payload["result"]["revision"], 7);
|
||||||
|
assert_eq!(payload["result"]["pageSubtree"]["rootNodeId"], "doc_1");
|
||||||
|
assert_eq!(
|
||||||
|
payload["result"]["pageSubtree"]["outline"][0]["title"],
|
||||||
|
"章节一"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn documents_save_route_executes_documents_save_command() {
|
||||||
|
let response = app()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/documents/save")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::json!({
|
||||||
|
"documentId": "doc_1",
|
||||||
|
"workspaceId": "ws_demo",
|
||||||
|
"revision": 7,
|
||||||
|
"conflictDetectionKey": "doc_1:7",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"id": "heading_1",
|
||||||
|
"type": "heading",
|
||||||
|
"props": { "level": 1 },
|
||||||
|
"content": [{ "type": "text", "text": "章节一(已编辑)" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"blockCount": 1
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.expect("body");
|
||||||
|
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||||
|
assert_eq!(payload["result"]["revision"], 8);
|
||||||
|
assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8");
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
mod bridge;
|
mod bridge;
|
||||||
mod command_support;
|
mod command_support;
|
||||||
mod compat;
|
mod compat;
|
||||||
|
mod documents;
|
||||||
|
mod editor;
|
||||||
mod health;
|
mod health;
|
||||||
mod hermes;
|
mod hermes;
|
||||||
mod kernel;
|
mod kernel;
|
||||||
@@ -22,6 +24,15 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/health", get(health::health))
|
.route("/health", get(health::health))
|
||||||
.route("/tree", get(tree::tree_shell))
|
.route("/tree", get(tree::tree_shell))
|
||||||
|
.route("/document-debug", get(editor::document_editor_shell))
|
||||||
|
.route("/document", get(editor::document_editor_shell))
|
||||||
|
.route("/api/documents/meta", get(documents::meta))
|
||||||
|
.route("/api/documents/content", get(documents::content))
|
||||||
|
.route("/api/documents/save", post(documents::save))
|
||||||
|
.route(
|
||||||
|
"/api/documents/runtime/transform",
|
||||||
|
post(editor::transform_runtime_snapshot),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/tree/projections/sidebar",
|
"/api/tree/projections/sidebar",
|
||||||
get(kernel::project_tree_sidebar),
|
get(kernel::project_tree_sidebar),
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use bridge_runtime::{
|
|||||||
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
|
RuntimeExecutionPlan, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeQueryExecutionPlan,
|
||||||
RuntimeSourceWire,
|
RuntimeSourceWire,
|
||||||
};
|
};
|
||||||
|
use core_protocol::{GetPageMeta, QueryEnvelope};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use storage_convex_bridge::{build_query_request, BridgeContext};
|
||||||
|
|
||||||
pub fn resolve_effective_workspace_id(
|
pub fn resolve_effective_workspace_id(
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -77,6 +79,31 @@ pub fn runtime_context(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn storage_context(
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
) -> BridgeContext {
|
||||||
|
BridgeContext {
|
||||||
|
deployment_id: context.workspace.deployment_id.clone(),
|
||||||
|
project_id: context.workspace.project_id.clone(),
|
||||||
|
request_id: context.trace.request_id.clone(),
|
||||||
|
trace_id: context.trace.trace_id.clone(),
|
||||||
|
actor_type: context.auth.actor_type.clone(),
|
||||||
|
actor_id: context.auth.actor_id.clone(),
|
||||||
|
session_id: context.auth.session_id.clone(),
|
||||||
|
workspace_id: effective_workspace_id
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| context.workspace.workspace_id.clone()),
|
||||||
|
tenant_id: context.workspace.tenant_id.clone(),
|
||||||
|
auth_token: context.auth.authorization.clone(),
|
||||||
|
source_channel: context.source.channel.clone(),
|
||||||
|
source_client: context.source.client.clone(),
|
||||||
|
idempotency_key: context.source.idempotency_key.clone(),
|
||||||
|
validate_only: false,
|
||||||
|
dry_run: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_runtime_query_plan(
|
pub fn build_runtime_query_plan(
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
effective_workspace_id: Option<&str>,
|
effective_workspace_id: Option<&str>,
|
||||||
@@ -96,6 +123,34 @@ pub fn build_runtime_query_plan(
|
|||||||
Ok(plan)
|
Ok(plan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_documents_meta_query_plan(
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
document_id: &str,
|
||||||
|
) -> Result<RuntimeQueryExecutionPlan, WebError> {
|
||||||
|
let query = QueryEnvelope {
|
||||||
|
name: "documents.meta.get".into(),
|
||||||
|
payload: GetPageMeta {
|
||||||
|
page_id: document_id.to_string(),
|
||||||
|
workspace_id: effective_workspace_id.map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let request = build_query_request(&storage_context(context, effective_workspace_id), &query)
|
||||||
|
.map_err(|error| WebError::bad_request(error.message).with_context(context))?;
|
||||||
|
Ok(RuntimeQueryExecutionPlan {
|
||||||
|
query_name: query.name,
|
||||||
|
function_name: request.function_name,
|
||||||
|
workspace_id: request.workspace_id,
|
||||||
|
request_id: request.request_id,
|
||||||
|
trace_id: request.trace_id,
|
||||||
|
actor_id: request.actor_id,
|
||||||
|
payload_json: request.payload_json,
|
||||||
|
args_json: serde_json::json!({
|
||||||
|
"id": document_id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_query_data_via_convex(
|
pub async fn fetch_query_data_via_convex(
|
||||||
config: &AppConfig,
|
config: &AppConfig,
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
@@ -106,6 +161,16 @@ pub async fn fetch_query_data_via_convex(
|
|||||||
execute_convex_query_plan(config, context, &plan).await
|
execute_convex_query_plan(config, context, &plan).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_documents_meta_via_convex(
|
||||||
|
config: &AppConfig,
|
||||||
|
context: &RequestContext,
|
||||||
|
effective_workspace_id: Option<&str>,
|
||||||
|
document_id: &str,
|
||||||
|
) -> Result<Value, WebError> {
|
||||||
|
let plan = build_documents_meta_query_plan(context, effective_workspace_id, document_id)?;
|
||||||
|
execute_convex_query_plan(config, context, &plan).await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn execute_runtime_query_against_data(
|
pub fn execute_runtime_query_against_data(
|
||||||
context: &RequestContext,
|
context: &RequestContext,
|
||||||
effective_workspace_id: Option<&str>,
|
effective_workspace_id: Option<&str>,
|
||||||
|
|||||||
@@ -261,7 +261,8 @@ mod tests {
|
|||||||
page_id: "page_2".into(),
|
page_id: "page_2".into(),
|
||||||
workspace_id: Some("ws_1".into()),
|
workspace_id: Some("ws_1".into()),
|
||||||
revision: Some(7),
|
revision: Some(7),
|
||||||
content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}".into(),
|
content_json: "{\"blocks\":[{\"id\":\"block_1\",\"type\":\"pageReference\"}]}"
|
||||||
|
.into(),
|
||||||
conflict_detection_key: Some("rev:7".into()),
|
conflict_detection_key: Some("rev:7".into()),
|
||||||
},
|
},
|
||||||
reason: Some("嵌入页面".into()),
|
reason: Some("嵌入页面".into()),
|
||||||
@@ -274,7 +275,9 @@ mod tests {
|
|||||||
build_write_request(&demo_context(), &command).expect("write request should build");
|
build_write_request(&demo_context(), &command).expect("write request should build");
|
||||||
assert_eq!(request.function_name, "documents:updateContent");
|
assert_eq!(request.function_name, "documents:updateContent");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request.payload_json.contains("\"name\":\"documents.embed\""));
|
assert!(request
|
||||||
|
.payload_json
|
||||||
|
.contains("\"name\":\"documents.embed\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -769,9 +772,7 @@ mod tests {
|
|||||||
build_query_request(&demo_context(), &query).expect("query request should build");
|
build_query_request(&demo_context(), &query).expect("query request should build");
|
||||||
assert_eq!(request.function_name, "search:recent");
|
assert_eq!(request.function_name, "search:recent");
|
||||||
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
|
||||||
assert!(request
|
assert!(request.payload_json.contains("\"name\":\"search.recent\""));
|
||||||
.payload_json
|
|
||||||
.contains("\"name\":\"search.recent\""));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -28,6 +28,34 @@ impl BridgeError {
|
|||||||
message: message.into(),
|
message: message.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: BridgeErrorKind::Unauthorized,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn conflict(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: BridgeErrorKind::Conflict,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not_found(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: BridgeErrorKind::NotFound,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rejected(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: BridgeErrorKind::Rejected,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type BridgeResult<T> = Result<T, BridgeError>;
|
pub type BridgeResult<T> = Result<T, BridgeError>;
|
||||||
|
|||||||
@@ -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 REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
|
||||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||||
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
const TEST_LOGIN_BUTTON_NAME = "测试账号快速登录";
|
||||||
|
const TEST_USERNAME_PREFIX = "测试用户";
|
||||||
|
|
||||||
function assert(condition, message) {
|
function assert(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -92,19 +93,101 @@ async function getViewerIdentity(requestContext) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForAuthenticatedRedirect(page, timeout = 8_000) {
|
||||||
|
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||||
|
timeout,
|
||||||
|
waitUntil: "commit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isVisible(locator) {
|
||||||
|
try {
|
||||||
|
return await locator.isVisible();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeUsernameSetupIfNeeded(page) {
|
||||||
|
const saveButton = page.getByRole("button", { name: "保存并继续" });
|
||||||
|
if (!(await isVisible(saveButton))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameInput = page.locator('input[name="username"]').last();
|
||||||
|
await usernameInput.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||||
|
const currentValue = ((await usernameInput.inputValue().catch(() => "")) || "").trim();
|
||||||
|
const username = currentValue || `${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`;
|
||||||
|
await usernameInput.fill(username, { timeout: UI_TIMEOUT_MS });
|
||||||
|
await saveButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerTestAccountIfNeeded(page) {
|
||||||
|
const switchButton = page.getByRole("button", { name: "还没有账户?立即注册" });
|
||||||
|
if (!(await isVisible(switchButton))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await switchButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('input[name="email"]').fill("test@example.com", { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.locator('input[name="username"]').fill(`${TEST_USERNAME_PREFIX}${Date.now().toString().slice(-6)}`, {
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.locator('input[name="password"]').fill("Test123456", { timeout: UI_TIMEOUT_MS });
|
||||||
|
await page.getByRole("button", { name: "注册" }).click({ timeout: UI_TIMEOUT_MS });
|
||||||
|
try {
|
||||||
|
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return completeUsernameSetupIfNeeded(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForViewerIdentity(requestContext, attempts = 6) {
|
||||||
|
let lastError = null;
|
||||||
|
for (let index = 0; index < attempts; index += 1) {
|
||||||
|
try {
|
||||||
|
return await getViewerIdentity(requestContext);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("获取当前用户失败");
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureAuthenticated(page, requestContext) {
|
async function ensureAuthenticated(page, requestContext) {
|
||||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
await page.goto(`${BASE_URL}/auth`, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||||
|
|
||||||
if (page.url().includes("/auth")) {
|
if (page.url().includes("/auth")) {
|
||||||
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
const quickLoginButton = page.getByRole("button", { name: TEST_LOGIN_BUTTON_NAME });
|
||||||
await quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
await Promise.race([
|
||||||
|
page.waitForURL((url) => !url.toString().includes("/auth"), {
|
||||||
|
timeout: UI_TIMEOUT_MS,
|
||||||
|
waitUntil: "commit",
|
||||||
|
}),
|
||||||
|
quickLoginButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }),
|
||||||
|
]);
|
||||||
|
if (!page.url().includes("/auth")) {
|
||||||
|
return await waitForViewerIdentity(requestContext);
|
||||||
|
}
|
||||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||||
await page.waitForURL((url) => !url.toString().includes("/auth"), {
|
try {
|
||||||
timeout: UI_TIMEOUT_MS,
|
await waitForAuthenticatedRedirect(page, UI_TIMEOUT_MS);
|
||||||
});
|
} catch {
|
||||||
|
const completedUsername = await completeUsernameSetupIfNeeded(page);
|
||||||
|
if (!completedUsername) {
|
||||||
|
const registered = await registerTestAccountIfNeeded(page);
|
||||||
|
if (!registered) {
|
||||||
|
throw new Error("测试账号登录后仍停留在 /auth,且未进入可恢复的用户名/注册流程");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getViewerIdentity(requestContext);
|
return await waitForViewerIdentity(requestContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareTempTreeFixture(requestContext) {
|
async function prepareTempTreeFixture(requestContext) {
|
||||||
@@ -137,7 +220,7 @@ async function cleanupDocuments(requestContext, createdIds) {
|
|||||||
|
|
||||||
async function openDocument(page, workspaceId, documentId) {
|
async function openDocument(page, workspaceId, documentId) {
|
||||||
const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
|
const url = `${BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
|
||||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
await page.goto(url, { waitUntil: "commit", timeout: UI_TIMEOUT_MS });
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
|||||||
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
|
import { fetchDocumentMetaViaBridge } from "@/lib/documents/bridge-server";
|
||||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||||
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
|
import { buildDocumentBridgeContext, buildDocumentQueryEnvelope } from "@/lib/documents/bridge";
|
||||||
import { buildPageSubtreeProjection } from "@/lib/documents/page-subtree";
|
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||||
|
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||||
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
|
import { executeRustBridgeQueryTransport, resolveRustBridgeQueryPlan } from "@/lib/documents/rust-runtime";
|
||||||
|
|
||||||
interface DocumentPageProps {
|
interface DocumentPageProps {
|
||||||
@@ -48,15 +49,18 @@ type DocumentContentPayload = {
|
|||||||
content?: unknown;
|
content?: unknown;
|
||||||
revision?: number | null;
|
revision?: number | null;
|
||||||
conflict_detection_key?: string | null;
|
conflict_detection_key?: string | null;
|
||||||
|
page_subtree?: PageSubtreeProjection | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchDocumentContentOnServer(input: {
|
async function fetchDocumentContentOnServer(input: {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
title?: string | null;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
content: unknown;
|
content: unknown;
|
||||||
revision: number | null;
|
revision: number | null;
|
||||||
conflictDetectionKey: string | null;
|
conflictDetectionKey: string | null;
|
||||||
|
pageSubtree: PageSubtreeProjection | null;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const headerList = await headers();
|
const headerList = await headers();
|
||||||
@@ -101,23 +105,19 @@ async function fetchDocumentContentOnServer(input: {
|
|||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
});
|
});
|
||||||
|
const normalized = normalizeDocumentContentResponse({
|
||||||
|
documentId: input.documentId,
|
||||||
|
title: input.title ?? null,
|
||||||
|
payload: result,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return normalized;
|
||||||
content: result?.content ?? null,
|
|
||||||
revision:
|
|
||||||
typeof result?.revision === "number" && Number.isInteger(result.revision)
|
|
||||||
? result.revision
|
|
||||||
: 0,
|
|
||||||
conflictDetectionKey:
|
|
||||||
typeof result?.conflict_detection_key === "string" && result.conflict_detection_key.trim()
|
|
||||||
? result.conflict_detection_key
|
|
||||||
: `${input.documentId}:0`,
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
content: null,
|
content: null,
|
||||||
revision: null,
|
revision: null,
|
||||||
conflictDetectionKey: null,
|
conflictDetectionKey: null,
|
||||||
|
pageSubtree: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
|||||||
const initialDocumentContent = await fetchDocumentContentOnServer({
|
const initialDocumentContent = await fetchDocumentContentOnServer({
|
||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
workspaceId: doc.workspace_id,
|
workspaceId: doc.workspace_id,
|
||||||
});
|
|
||||||
const initialPageSubtree = buildPageSubtreeProjection({
|
|
||||||
documentId: doc.id,
|
|
||||||
title: doc.title ?? "无标题",
|
title: doc.title ?? "无标题",
|
||||||
content: initialDocumentContent.content,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -187,7 +183,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
|||||||
initialContent={initialDocumentContent.content}
|
initialContent={initialDocumentContent.content}
|
||||||
initialContentRevision={initialDocumentContent.revision}
|
initialContentRevision={initialDocumentContent.revision}
|
||||||
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
|
initialConflictDetectionKey={initialDocumentContent.conflictDetectionKey}
|
||||||
initialPageSubtree={initialPageSubtree}
|
initialPageSubtree={initialDocumentContent.pageSubtree}
|
||||||
initialOptions={initialOptions}
|
initialOptions={initialOptions}
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
openTableId={openTableId}
|
openTableId={openTableId}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
buildDocumentQueryEnvelope,
|
buildDocumentQueryEnvelope,
|
||||||
documentBridgeErrorResponse,
|
documentBridgeErrorResponse,
|
||||||
} from "@/lib/documents/bridge";
|
} from "@/lib/documents/bridge";
|
||||||
|
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||||
|
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||||
import {
|
import {
|
||||||
executeRustBridgeQueryTransport,
|
executeRustBridgeQueryTransport,
|
||||||
resolveRustBridgeQueryPlan,
|
resolveRustBridgeQueryPlan,
|
||||||
@@ -35,6 +37,8 @@ export async function GET(request: Request) {
|
|||||||
content?: unknown;
|
content?: unknown;
|
||||||
revision?: number | null;
|
revision?: number | null;
|
||||||
conflict_detection_key?: string | null;
|
conflict_detection_key?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
page_subtree?: PageSubtreeProjection | null;
|
||||||
} | null>({
|
} | null>({
|
||||||
client,
|
client,
|
||||||
plan,
|
plan,
|
||||||
@@ -54,16 +58,17 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeDocumentContentResponse({
|
||||||
|
documentId,
|
||||||
|
title: typeof result.title === "string" && result.title.trim() ? result.title : null,
|
||||||
|
payload: result,
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
content: result.content ?? null,
|
content: normalized.content,
|
||||||
revision:
|
revision: normalized.revision,
|
||||||
typeof result.revision === "number" && Number.isInteger(result.revision)
|
conflictDetectionKey: normalized.conflictDetectionKey,
|
||||||
? result.revision
|
pageSubtree: normalized.pageSubtree,
|
||||||
: 0,
|
|
||||||
conflictDetectionKey:
|
|
||||||
typeof result.conflict_detection_key === "string" && result.conflict_detection_key.trim()
|
|
||||||
? result.conflict_detection_key
|
|
||||||
: `${documentId}:0`,
|
|
||||||
meta: {
|
meta: {
|
||||||
requestId: bridgeContext.requestId,
|
requestId: bridgeContext.requestId,
|
||||||
traceId: bridgeContext.traceId,
|
traceId: bridgeContext.traceId,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DocumentToc } from "@/components/editor/document-toc";
|
import { DocumentToc } from "@/components/editor/document-toc";
|
||||||
import { DocumentReadView } from "@/components/editor/document-read-view";
|
import { DocumentReadView } from "@/components/editor/document-read-view";
|
||||||
import { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
import { extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||||
import {
|
import {
|
||||||
deleteDocumentCommand,
|
deleteDocumentCommand,
|
||||||
embedDocumentCommand,
|
embedDocumentCommand,
|
||||||
@@ -143,6 +143,11 @@ export function DocumentContent({
|
|||||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||||
const [content, setContent] = useState<unknown>(initialContent);
|
const [content, setContent] = useState<unknown>(initialContent);
|
||||||
|
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
|
||||||
|
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
|
||||||
|
initialPageSubtree,
|
||||||
|
);
|
||||||
|
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(title ?? "无标题");
|
||||||
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
|
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
|
||||||
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
|
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
|
||||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||||
@@ -259,6 +264,14 @@ export function DocumentContent({
|
|||||||
setPageTitle(title ?? "无标题");
|
setPageTitle(title ?? "无标题");
|
||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServerPageSubtreeTitle(title ?? "无标题");
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServerPageSubtreeSnapshot(initialPageSubtree);
|
||||||
|
}, [initialPageSubtree]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOptions(initialOptions ?? defaultOptions);
|
setOptions(initialOptions ?? defaultOptions);
|
||||||
}, [initialOptions]);
|
}, [initialOptions]);
|
||||||
@@ -275,6 +288,10 @@ export function DocumentContent({
|
|||||||
setConflictDetectionKey(initialConflictDetectionKey);
|
setConflictDetectionKey(initialConflictDetectionKey);
|
||||||
}, [initialConflictDetectionKey]);
|
}, [initialConflictDetectionKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServerContentSnapshot(initialContent);
|
||||||
|
}, [initialContent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextBlocks = extractPageBlocks(content);
|
const nextBlocks = extractPageBlocks(content);
|
||||||
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
|
latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null;
|
||||||
@@ -354,9 +371,11 @@ export function DocumentContent({
|
|||||||
content?: unknown;
|
content?: unknown;
|
||||||
revision?: number | null;
|
revision?: number | null;
|
||||||
conflictDetectionKey?: string | null;
|
conflictDetectionKey?: string | null;
|
||||||
|
pageSubtree?: PageSubtreeProjection | null;
|
||||||
};
|
};
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
setContent(payload.content ?? null);
|
setContent(payload.content ?? null);
|
||||||
|
setServerContentSnapshot(payload.content ?? null);
|
||||||
setContentRevision(
|
setContentRevision(
|
||||||
typeof payload.revision === "number" && Number.isInteger(payload.revision)
|
typeof payload.revision === "number" && Number.isInteger(payload.revision)
|
||||||
? payload.revision
|
? payload.revision
|
||||||
@@ -367,6 +386,13 @@ export function DocumentContent({
|
|||||||
? payload.conflictDetectionKey
|
? payload.conflictDetectionKey
|
||||||
: `${documentId}:0`,
|
: `${documentId}:0`,
|
||||||
);
|
);
|
||||||
|
setServerPageSubtreeSnapshot(payload.pageSubtree ?? null);
|
||||||
|
setServerPageSubtreeTitle(
|
||||||
|
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
|
||||||
|
payload.pageSubtree.rootNode.metadata.title.trim()
|
||||||
|
? payload.pageSubtree.rootNode.metadata.title
|
||||||
|
: title ?? "无标题",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
if ((error as { name?: string })?.name === "AbortError") return;
|
if ((error as { name?: string })?.name === "AbortError") return;
|
||||||
@@ -393,7 +419,7 @@ export function DocumentContent({
|
|||||||
contentLoadingTimerRef.current = null;
|
contentLoadingTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [documentId, initialContent, contentReloadKey, workspaceId]);
|
}, [documentId, initialContent, contentReloadKey, title, workspaceId]);
|
||||||
|
|
||||||
const persistTitle = useCallback(
|
const persistTitle = useCallback(
|
||||||
async (nextTitle: string) => {
|
async (nextTitle: string) => {
|
||||||
@@ -730,18 +756,24 @@ export function DocumentContent({
|
|||||||
options.hideChildPages && "wolai-hide-child-pages",
|
options.hideChildPages && "wolai-hide-child-pages",
|
||||||
);
|
);
|
||||||
const pageSubtree = useMemo(() => {
|
const pageSubtree = useMemo(() => {
|
||||||
if (initialPageSubtree && content === initialContent && pageTitle === (title ?? "无标题")) {
|
const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
|
||||||
return initialPageSubtree;
|
const titleUnchanged = pageTitle === serverPageSubtreeTitle;
|
||||||
|
const contentUnchanged = content === serverContentSnapshot;
|
||||||
|
|
||||||
|
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
|
||||||
|
return serverPageSubtreeSnapshot;
|
||||||
}
|
}
|
||||||
return buildPageSubtreeProjection({
|
return null;
|
||||||
documentId,
|
}, [
|
||||||
title: pageTitle,
|
content,
|
||||||
content,
|
pageTitle,
|
||||||
});
|
serverContentSnapshot,
|
||||||
}, [content, documentId, initialContent, initialPageSubtree, pageTitle, title]);
|
serverPageSubtreeSnapshot,
|
||||||
|
serverPageSubtreeTitle,
|
||||||
|
]);
|
||||||
const readViewTocEntries = useMemo(
|
const readViewTocEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pageSubtree.outline
|
(pageSubtree?.outline ?? [])
|
||||||
.filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim())
|
.filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim())
|
||||||
.map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({
|
.map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({
|
||||||
id: anchorBlockId as string,
|
id: anchorBlockId as string,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { CSSProperties, ReactNode } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TocEntry } from "@/components/editor/document-toc";
|
import type { TocEntry } from "@/components/editor/document-toc";
|
||||||
import {
|
import {
|
||||||
buildPageSubtreeProjection,
|
|
||||||
clampHeadingLevel,
|
clampHeadingLevel,
|
||||||
extractPageBlocks,
|
extractPageBlocks,
|
||||||
getInlineText,
|
getInlineText,
|
||||||
@@ -181,11 +180,26 @@ const buildHeadingNumberingMapFromOutline = (outline: PageOutlineEntry[]): Map<s
|
|||||||
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
|
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
|
||||||
|
|
||||||
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
|
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
|
||||||
return buildPageSubtreeProjection({
|
const counters = [0, 0, 0, 0, 0];
|
||||||
documentId: "preview",
|
return blocks.flatMap((block) => {
|
||||||
title: "预览",
|
if (block.type !== "heading") {
|
||||||
content: blocks,
|
return [];
|
||||||
}).outline.map(({ id, level, numbering, title }) => ({
|
}
|
||||||
|
const level = clampHeadingLevel(block.props?.level);
|
||||||
|
counters[level - 1] += 1;
|
||||||
|
for (let index = level; index < counters.length; index += 1) {
|
||||||
|
counters[index] = 0;
|
||||||
|
}
|
||||||
|
return [{
|
||||||
|
id: String(block.id ?? `preview-heading-${counters.join("-")}`),
|
||||||
|
level,
|
||||||
|
numbering: counters
|
||||||
|
.slice(0, level)
|
||||||
|
.filter((value) => value > 0)
|
||||||
|
.join("."),
|
||||||
|
title: getInlineText(block.content) || "未命名标题",
|
||||||
|
}];
|
||||||
|
}).map(({ id, level, numbering, title }) => ({
|
||||||
id,
|
id,
|
||||||
level,
|
level,
|
||||||
numbering,
|
numbering,
|
||||||
@@ -583,22 +597,15 @@ function DocumentReadStructurePanel({ pageSubtree }: { pageSubtree: PageSubtreeP
|
|||||||
|
|
||||||
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
|
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
|
||||||
const blocks = extractReadViewBlocks(content);
|
const blocks = extractReadViewBlocks(content);
|
||||||
const resolvedPageSubtree =
|
|
||||||
pageSubtree ??
|
|
||||||
buildPageSubtreeProjection({
|
|
||||||
documentId,
|
|
||||||
title: null,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
const headingNumberingById =
|
const headingNumberingById =
|
||||||
resolvedPageSubtree.outline.length > 0
|
(pageSubtree?.outline.length ?? 0) > 0
|
||||||
? buildHeadingNumberingMapFromOutline(resolvedPageSubtree.outline)
|
? buildHeadingNumberingMapFromOutline(pageSubtree?.outline ?? [])
|
||||||
: buildHeadingNumberingMap(blocks);
|
: buildHeadingNumberingMap(blocks);
|
||||||
|
|
||||||
if (blocks.length === 0) {
|
if (blocks.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
|
{options.showStructure && pageSubtree ? <DocumentReadStructurePanel pageSubtree={pageSubtree} /> : null}
|
||||||
<div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]">
|
<div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]">
|
||||||
页面暂无正文内容
|
页面暂无正文内容
|
||||||
</div>
|
</div>
|
||||||
@@ -608,7 +615,7 @@ export function DocumentReadView({ content, documentId, options, pageSubtree, cl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
|
{options.showStructure && pageSubtree ? <DocumentReadStructurePanel pageSubtree={pageSubtree} /> : null}
|
||||||
{renderBlocks(blocks, options, documentId, headingNumberingById, 0)}
|
{renderBlocks(blocks, options, documentId, headingNumberingById, 0)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeDocumentContentResponse } from "@/lib/documents/page-subtree-response";
|
||||||
|
import type { PageSubtreeProjection } from "@/lib/documents/page-subtree";
|
||||||
|
|
||||||
|
describe("page subtree response helpers", () => {
|
||||||
|
it("保留服务端返回的 pageSubtree", () => {
|
||||||
|
const serverPageSubtree = {
|
||||||
|
projectionId: "server_projection",
|
||||||
|
projection: "page_tree",
|
||||||
|
rootNodeId: "doc_1",
|
||||||
|
rootNode: {
|
||||||
|
id: "doc_1",
|
||||||
|
parentNodeId: null,
|
||||||
|
nodeType: "page",
|
||||||
|
blockId: null,
|
||||||
|
anchorBlockId: null,
|
||||||
|
depth: 0,
|
||||||
|
metadata: {
|
||||||
|
title: "服务端标题",
|
||||||
|
textSnippet: null,
|
||||||
|
blockType: null,
|
||||||
|
headingLevel: null,
|
||||||
|
numbering: null,
|
||||||
|
childCount: 0,
|
||||||
|
order: 0,
|
||||||
|
path: ["doc_1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subtree: {
|
||||||
|
rootNodeId: "doc_1",
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
outline: [],
|
||||||
|
evidence: [],
|
||||||
|
stats: {
|
||||||
|
blockCount: 0,
|
||||||
|
headingCount: 0,
|
||||||
|
evidenceCount: 0,
|
||||||
|
maxDepth: 0,
|
||||||
|
},
|
||||||
|
} satisfies PageSubtreeProjection;
|
||||||
|
|
||||||
|
const normalized = normalizeDocumentContentResponse({
|
||||||
|
documentId: "doc_1",
|
||||||
|
payload: {
|
||||||
|
content: null,
|
||||||
|
revision: 1,
|
||||||
|
conflictDetectionKey: "doc_1:1",
|
||||||
|
pageSubtree: serverPageSubtree,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.pageSubtree).toBe(serverPageSubtree);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("在服务端缺失 pageSubtree 时保留空值", () => {
|
||||||
|
const normalized = normalizeDocumentContentResponse({
|
||||||
|
documentId: "doc_1",
|
||||||
|
title: "测试页面",
|
||||||
|
payload: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
id: "heading_1",
|
||||||
|
type: "heading",
|
||||||
|
props: { level: 1 },
|
||||||
|
content: [{ type: "text", text: "章节一" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
revision: 3,
|
||||||
|
conflictDetectionKey: "doc_1:3",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.revision).toBe(3);
|
||||||
|
expect(normalized.conflictDetectionKey).toBe("doc_1:3");
|
||||||
|
expect(normalized.pageSubtree).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
type PageSubtreeProjection,
|
||||||
|
} from "@/lib/documents/page-subtree";
|
||||||
|
|
||||||
|
export type DocumentContentResponseLike = {
|
||||||
|
content?: unknown;
|
||||||
|
revision?: number | null;
|
||||||
|
conflict_detection_key?: string | null;
|
||||||
|
conflictDetectionKey?: string | null;
|
||||||
|
page_subtree?: PageSubtreeProjection | null;
|
||||||
|
pageSubtree?: PageSubtreeProjection | null;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizedDocumentContentResponse = {
|
||||||
|
content: unknown;
|
||||||
|
revision: number;
|
||||||
|
conflictDetectionKey: string;
|
||||||
|
pageSubtree: PageSubtreeProjection | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeDocumentContentResponse(input: {
|
||||||
|
documentId: string;
|
||||||
|
title?: string | null;
|
||||||
|
payload?: DocumentContentResponseLike | null;
|
||||||
|
}): NormalizedDocumentContentResponse {
|
||||||
|
const payload = input.payload ?? null;
|
||||||
|
const content = payload?.content ?? null;
|
||||||
|
const revision =
|
||||||
|
typeof payload?.revision === "number" && Number.isInteger(payload.revision)
|
||||||
|
? payload.revision
|
||||||
|
: 0;
|
||||||
|
const conflictDetectionKey = (
|
||||||
|
typeof payload?.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
|
||||||
|
? payload.conflictDetectionKey
|
||||||
|
: typeof payload?.conflict_detection_key === "string" && payload.conflict_detection_key.trim()
|
||||||
|
? payload.conflict_detection_key
|
||||||
|
: `${input.documentId}:0`
|
||||||
|
);
|
||||||
|
const pageSubtree = payload?.pageSubtree ?? payload?.page_subtree ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
revision,
|
||||||
|
conflictDetectionKey,
|
||||||
|
pageSubtree,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -73,6 +73,36 @@ const parseRuntimeBoolean = (value: unknown): boolean | undefined => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getServerNodeBuiltin<T>(moduleName: string): T | null {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processWithBuiltin = process as NodeJS.Process & {
|
||||||
|
getBuiltinModule?: (id: string) => unknown;
|
||||||
|
};
|
||||||
|
if (typeof processWithBuiltin.getBuiltinModule === "function") {
|
||||||
|
const builtIn = processWithBuiltin.getBuiltinModule(moduleName);
|
||||||
|
if (builtIn) {
|
||||||
|
return builtIn as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 说明:某些 Node/打包环境仍保留 CommonJS require,这里只作为兜底。
|
||||||
|
const runtimeRequire = new Function(
|
||||||
|
'return typeof require === "function" ? require : null',
|
||||||
|
)() as NodeJS.Require | null;
|
||||||
|
if (runtimeRequire) {
|
||||||
|
return runtimeRequire(moduleName) as T;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const readFromEnv = (): MnoteRuntimeConfig => ({
|
const readFromEnv = (): MnoteRuntimeConfig => ({
|
||||||
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
|
useConvex: process.env.USE_CONVEX === "1" || process.env.NEXT_PUBLIC_USE_CONVEX === "1",
|
||||||
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
@@ -111,12 +141,14 @@ const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
|
|||||||
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
|
||||||
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
|
||||||
|
|
||||||
// 说明:这里不能直接写 `require("fs")` / `require("path")`,
|
// 说明:这里不能直接静态引入 `node:fs` / `node:path`,
|
||||||
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖,触发持续重编译或空白页。
|
// 否则客户端 bundle 在解析该模块时会把它们也当成浏览器依赖。
|
||||||
// 仅在服务端运行时通过惰性 require 读取本地 public/mnote-env.json。
|
// Node 22 优先走 `process.getBuiltinModule`,其余环境再兜底到 runtime require。
|
||||||
const runtimeRequire = new Function("return require")() as NodeJS.Require;
|
const fs = getServerNodeBuiltin<typeof import("fs")>("node:fs");
|
||||||
const fs = runtimeRequire("node:fs") as typeof import("fs");
|
const path = getServerNodeBuiltin<typeof import("path")>("node:path");
|
||||||
const path = runtimeRequire("node:path") as typeof import("path");
|
if (!fs || !path) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`,
|
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`,
|
||||||
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
|
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
|
||||||
@@ -224,18 +256,28 @@ export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
|
|||||||
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
|
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
|
||||||
}
|
}
|
||||||
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
||||||
|
const publicRuntime = readFromPublicJson();
|
||||||
|
const envRuntime = readFromEnv();
|
||||||
// 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
|
// 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
|
||||||
// Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被
|
// Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被
|
||||||
// public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
|
// public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
|
||||||
const merged: MnoteRuntimeConfig = isDesktop
|
const merged: MnoteRuntimeConfig = isDesktop
|
||||||
? {
|
? {
|
||||||
...readFromEnv(),
|
...envRuntime,
|
||||||
...readFromPublicJson(),
|
...publicRuntime,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
...readFromPublicJson(),
|
...publicRuntime,
|
||||||
...readFromEnv(),
|
...envRuntime,
|
||||||
|
// 说明:Rust Web 的 tree shell 属于运行期开关,必须允许 public/mnote-env.json
|
||||||
|
// 在网页端覆盖环境变量;否则开发机上的旧 NEXT_PUBLIC_* 会把显式开关吃掉。
|
||||||
|
...(publicRuntime.mnoteWebBaseUrl !== undefined
|
||||||
|
? { mnoteWebBaseUrl: publicRuntime.mnoteWebBaseUrl }
|
||||||
|
: {}),
|
||||||
|
...(publicRuntime.mnoteWebTreeShellEnabled !== undefined
|
||||||
|
? { mnoteWebTreeShellEnabled: publicRuntime.mnoteWebTreeShellEnabled }
|
||||||
|
: {}),
|
||||||
isDesktop,
|
isDesktop,
|
||||||
};
|
};
|
||||||
return normalizeRuntimeConfig(merged);
|
return normalizeRuntimeConfig(merged);
|
||||||
|
|||||||
Reference in New Issue
Block a user