feat: finish rust web dual pane document shell
This commit is contained in:
@@ -0,0 +1,795 @@
|
||||
# 3-14 [process] Rust Web 双窗格可编辑文档壳方案 v1
|
||||
|
||||
> 更新时间:2026-05-08
|
||||
>
|
||||
> 关联文档:
|
||||
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-rust-web-long-term-architecture-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/03-rust-web/process/3-13-rust-web-local-markdown-gfm-ast-parser-migration-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-5-page-aggregate-single-truth-alignment-v1.md`
|
||||
> - `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
|
||||
## 1. 结论
|
||||
|
||||
当前 `3000` 已经把“在右侧边栏打开”挂到了右键菜单和搜索提示上,但实际只发出了 `tree.page.open-right` 事件,还没有真正的右侧文档容器、窗格状态和多实例同步模型。
|
||||
|
||||
这一步不应该做成“再开一个完整页面”或“在右侧塞一个 iframe”,而应该在现有 `workspace shell` 内引入:
|
||||
|
||||
- 双窗格文档宿主
|
||||
- 可复用的文档 session 层
|
||||
- 可复用的 tree / local-folder 事件流
|
||||
|
||||
目标是做到接近 Wolai / VSCode 的第一阶段体验:
|
||||
|
||||
- 左侧主文档可继续导航
|
||||
- 右侧文档独立保持不变
|
||||
- 右侧可直接编辑
|
||||
- 同一文档允许同时在左右两个 pane 打开
|
||||
- 左右任一侧编辑,另一侧即时同步
|
||||
- watcher / SSE 数量不随 pane 数线性膨胀
|
||||
|
||||
## 2. 当前问题
|
||||
|
||||
### 2.1 入口已存在,但容器不存在
|
||||
|
||||
当前右键菜单中的“在右侧边栏打开”会派发:
|
||||
|
||||
- `tree.page.open-right`
|
||||
|
||||
但 `mnote-web` 还没有:
|
||||
|
||||
- `secondary pane` 宿主
|
||||
- pane 状态模型
|
||||
- 右侧文档加载/关闭/替换逻辑
|
||||
- 多编辑器实例同步机制
|
||||
|
||||
所以当前缺的不是单个按钮,而是整套双窗格运行时。
|
||||
|
||||
### 2.2 不能用“再开一页”糊过去
|
||||
|
||||
如果把右侧打开实现为:
|
||||
|
||||
- 新窗口
|
||||
- iframe
|
||||
- 第二个完整 document shell 页面
|
||||
|
||||
那么会重复创建:
|
||||
|
||||
- 标题控制器
|
||||
- editor bootstrap
|
||||
- tree SSE
|
||||
- local-folder EventSource
|
||||
- 保存队列
|
||||
- 外部变更检测
|
||||
|
||||
这样做短期能跑,长期会把实时同步、冲突检测和性能问题一起放大。
|
||||
|
||||
### 2.3 本地 Markdown 和 Convex Workspace 都需要纳入统一模型
|
||||
|
||||
当前文档来源至少有两类:
|
||||
|
||||
- `convex_workspace`
|
||||
- `local_folder`
|
||||
|
||||
双窗格方案必须同时覆盖这两类来源,不能只对 Convex 页面生效,再把本地 Markdown 排除到特殊路径。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
- 在单一 `workspace shell` 内支持 `primary pane + secondary pane`
|
||||
- 右侧 pane 默认直接可编辑
|
||||
- 左侧切换页面时,右侧 pane 保持不变
|
||||
- 支持同一文档同时在左右两个 pane 打开
|
||||
- 同一文档双开时,左右编辑器共享同一份文档 session,并即时同步
|
||||
- 复用 tree realtime 和 local-folder 外部变更链路,不按 pane 数重复建立 watcher
|
||||
- reload 后恢复双窗格状态
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
- 这一步不直接做 VSCode 式多 group + tabs
|
||||
- 这一步不做跨浏览器窗口的协同光标
|
||||
- 这一步不把 page aggregate、title controller、settings 全部重构一遍
|
||||
- 这一步不处理“无限多个右侧 pane”
|
||||
- 这一步不做右侧 pane 的独立页面树
|
||||
|
||||
## 5. 方案比较
|
||||
|
||||
### 方案 A:右侧嵌完整 document shell / iframe
|
||||
|
||||
优点:
|
||||
|
||||
- 实现快
|
||||
|
||||
缺点:
|
||||
|
||||
- 会复制整套文档运行时
|
||||
- watcher / SSE / 保存逻辑重复
|
||||
- 同一文档双开难以稳定同步
|
||||
- 后续演进到 tabs / groups 时基本要重做
|
||||
|
||||
### 方案 B:单一 workspace shell + pane manager + document session registry
|
||||
|
||||
优点:
|
||||
|
||||
- 单一页面壳、单一树事件流
|
||||
- 文档实例和外层壳职责清晰
|
||||
- 同一文档双开可以共享 session
|
||||
- 最适合作为后续 tabs / groups 的基础
|
||||
|
||||
缺点:
|
||||
|
||||
- 需要补一层 pane/session 运行时
|
||||
- 需要收口 editor 实例与 session 的边界
|
||||
|
||||
### 方案 C:直接做 VSCode 风格 editor groups + tabs
|
||||
|
||||
优点:
|
||||
|
||||
- 长期形态最强
|
||||
|
||||
缺点:
|
||||
|
||||
- 范围明显过大
|
||||
- 当前没有 tab/group/session 三层基础,直接做会把任务扩散
|
||||
|
||||
### 推荐
|
||||
|
||||
选方案 B。
|
||||
|
||||
它能在不过度扩面的前提下,把真正需要的底层模型一次收对。
|
||||
|
||||
## 6. 交互契约
|
||||
|
||||
### 6.1 窗格结构
|
||||
|
||||
页面维持一个共享 `workspace shell`,内容区拆为:
|
||||
|
||||
- `primary pane`
|
||||
- `secondary pane`
|
||||
|
||||
共享部分只有一份:
|
||||
|
||||
- 左侧树
|
||||
- 顶部壳
|
||||
- workspace 级搜索
|
||||
- tree realtime stream
|
||||
|
||||
### 6.2 打开规则
|
||||
|
||||
普通点击页面树:
|
||||
|
||||
- 继续只驱动 `primary pane`
|
||||
|
||||
“在右侧边栏打开”:
|
||||
|
||||
- 若右侧未打开,则创建 `secondary pane`
|
||||
- 若右侧已打开,则替换右侧当前文档
|
||||
- 不影响 `primary pane`
|
||||
|
||||
### 6.3 保持规则
|
||||
|
||||
当左侧主 pane 切换页面时:
|
||||
|
||||
- `secondary pane` 保持不变
|
||||
|
||||
只有以下操作会改变右侧内容:
|
||||
|
||||
- 再次执行“在右侧边栏打开”
|
||||
- 用户主动在右侧 pane 内导航
|
||||
- 用户关闭右侧 pane
|
||||
|
||||
### 6.4 同文档双开规则
|
||||
|
||||
允许同一文档同时出现在左右两个 pane:
|
||||
|
||||
- 两侧都可编辑
|
||||
- 任一侧输入后,另一侧即时同步
|
||||
- 保存只走同一个共享 session 的保存队列
|
||||
|
||||
这一步的目标是接近 VSCode:
|
||||
|
||||
- 同一文件可在两个 editor view 中同时打开
|
||||
- 修改后另一侧无需 reload 即更新
|
||||
|
||||
### 6.5 关闭与布局规则
|
||||
|
||||
第一阶段支持:
|
||||
|
||||
- 关闭右侧 pane
|
||||
- 拖动分割线调整宽度
|
||||
|
||||
第一阶段不支持:
|
||||
|
||||
- 多个右侧 pane
|
||||
- 窗格 tab bar
|
||||
- 拖拽重排成多个 group
|
||||
|
||||
## 7. URL 与恢复策略
|
||||
|
||||
为保证 reload、复制链接和恢复状态稳定,建议 URL 扩展为:
|
||||
|
||||
- 主文档仍使用当前 `documentId`
|
||||
- 右侧文档增加 `secondaryDocumentId`
|
||||
|
||||
对于本地 Markdown,再补:
|
||||
|
||||
- `secondarySourceKind`
|
||||
- `secondaryRootUri`
|
||||
|
||||
示意:
|
||||
|
||||
```text
|
||||
/documents/doc_main?workspaceId=ws_demo&secondaryDocumentId=doc_side
|
||||
```
|
||||
|
||||
本地 Markdown 示例:
|
||||
|
||||
```text
|
||||
/documents/local-md:README.md?sourceKind=local_folder&rootUri=file:///root&secondaryDocumentId=local-md:guide.md&secondarySourceKind=local_folder&secondaryRootUri=file:///root
|
||||
```
|
||||
|
||||
恢复规则:
|
||||
|
||||
- 页面刷新后,按 URL 先恢复 `primary pane`
|
||||
- 若存在 `secondaryDocumentId`,再恢复右侧 pane
|
||||
- 若右侧文档失效,则降级为仅保留主 pane,并清理失效的 secondary 参数
|
||||
|
||||
## 8. 运行时分层
|
||||
|
||||
### 8.1 Pane Manager
|
||||
|
||||
新增 `PaneManager`,只负责:
|
||||
|
||||
- 当前布局是否双栏
|
||||
- `primary / secondary` 各自绑定哪个文档
|
||||
- 当前聚焦 pane
|
||||
- 右侧 pane 的宽度状态
|
||||
|
||||
它不负责:
|
||||
|
||||
- 文档内容真相
|
||||
- 保存逻辑
|
||||
- 外部文件 watcher
|
||||
|
||||
### 8.2 Document Session Registry
|
||||
|
||||
新增 `DocumentSessionRegistry`,按“文档来源键”维护共享 session。
|
||||
|
||||
建议 session key 形状:
|
||||
|
||||
```text
|
||||
{sourceKind}:{workspaceId or rootUri}:{documentId}
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
- `convex_workspace:ws_demo:doc_1`
|
||||
- `local_folder:file:///mnt/Data1T/mnote/design:local-md:README.md`
|
||||
|
||||
每个 session 负责:
|
||||
|
||||
- 最新 `PageAggregate` 快照
|
||||
- editor 共享 revision / conflictDetectionKey
|
||||
- dirty 状态
|
||||
- 保存队列
|
||||
- 外部变更状态
|
||||
- 已挂载的 editor view 列表
|
||||
|
||||
### 8.3 Editor View
|
||||
|
||||
每个 pane 内的实际编辑器实例叫 `EditorViewBinding`。
|
||||
|
||||
它只负责:
|
||||
|
||||
- 渲染当前 session 内容
|
||||
- 把本地输入回传到 session
|
||||
- 接收 session 推送并更新本 view
|
||||
- 维护本 view 的 selection / focus
|
||||
|
||||
因此:
|
||||
|
||||
- 一个 session 可挂多个 editor view
|
||||
- 左右双开同一文档时,是“两个 view 绑定同一个 session”
|
||||
|
||||
## 9. 同文档双开同步模型
|
||||
|
||||
### 9.1 为什么不能只靠保存后 reload
|
||||
|
||||
如果左右同步只依赖:
|
||||
|
||||
- 一侧保存
|
||||
- 另一侧通过 page aggregate reload
|
||||
|
||||
那么会出现:
|
||||
|
||||
- 输入延迟可见
|
||||
- 正在编辑时 selection 容易抖动
|
||||
- 双侧频繁互相 replaceContent
|
||||
|
||||
这不符合“同一文档双开”的编辑预期。
|
||||
|
||||
### 9.2 推荐同步口径
|
||||
|
||||
同一 session 下的多个 editor view 应采用:
|
||||
|
||||
- session 内即时内存同步
|
||||
- session 到后端的单一保存队列
|
||||
- 后端返回成功后再统一推进 revision / conflict key
|
||||
|
||||
也就是:
|
||||
|
||||
1. 左侧输入
|
||||
2. 左侧 view 把变更交给 session
|
||||
3. session 更新内存态
|
||||
4. session 向右侧 view 广播变更
|
||||
5. session 统一防抖保存
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 左右即时同步
|
||||
- 只保存一次
|
||||
- revision / conflictDetectionKey 始终只有一份
|
||||
|
||||
### 9.3 Selection 规则
|
||||
|
||||
同步内容时不强制同步对侧 selection。
|
||||
|
||||
即:
|
||||
|
||||
- 同步文档内容
|
||||
- 不同步光标和滚动位置
|
||||
|
||||
这是第一阶段最稳的边界。
|
||||
|
||||
## 10. Tree Realtime 与 Watcher 复用
|
||||
|
||||
### 10.1 Tree SSE
|
||||
|
||||
每个浏览器窗口只保留一条 workspace 级 tree realtime 连接。
|
||||
|
||||
它属于 `workspace shell`,不属于某个 pane。
|
||||
|
||||
因此:
|
||||
|
||||
- 不因为打开右侧 pane 再创建第二条 tree SSE
|
||||
|
||||
### 10.2 Frontend Local Folder Event Registry
|
||||
|
||||
本地 Markdown 外部变更建议新增前端 `LocalFolderEventRegistry`:
|
||||
|
||||
- 按 `rootUri` 复用 EventSource
|
||||
- 一个 `rootUri` 只建立一条 `/api/local-folder/events`
|
||||
- 命中的文件变化再按 `documentId` 分发到对应 session
|
||||
|
||||
这样:
|
||||
|
||||
- 同一根目录下左右打开两个 Markdown 文件,不会创建两条 EventSource
|
||||
- 同一 Markdown 在左右双开,也不会创建两条 EventSource
|
||||
|
||||
### 10.3 Backend Local Folder Watcher Registry
|
||||
|
||||
Rust 侧继续往前收口,新增或演进为 `LocalFolderWatcherRegistry`:
|
||||
|
||||
- 按 `rootUri` 维护单例 watcher
|
||||
- 一个 `rootUri` 在单个服务进程内只保留一个 `notify` watcher
|
||||
- 多个 SSE 订阅者共享 watcher 输出
|
||||
|
||||
watcher 发出的应是:
|
||||
|
||||
- 相对路径变化
|
||||
- 事件类型
|
||||
|
||||
再由 session 判断:
|
||||
|
||||
- 当前文档是否命中
|
||||
- 当前是否 dirty
|
||||
- 是自动同步还是冲突提示
|
||||
|
||||
### 10.4 设计目标
|
||||
|
||||
最终目标不是:
|
||||
|
||||
- `N 个 pane = N 个 watcher`
|
||||
|
||||
而是:
|
||||
|
||||
- `同窗口同 workspace` 下 tree SSE 恒为 1
|
||||
- `同窗口同 rootUri` 下 local-folder EventSource 恒为 1
|
||||
- `同进程同 rootUri` 下 Rust notify watcher 恒为 1
|
||||
|
||||
## 11. 保存与冲突策略
|
||||
|
||||
### 11.1 Convex Workspace
|
||||
|
||||
同一 session 的多个 editor view 共享一份:
|
||||
|
||||
- dirty 状态
|
||||
- saving 状态
|
||||
- revision
|
||||
|
||||
只要 session 正在保存:
|
||||
|
||||
- 两侧都显示同一保存状态
|
||||
|
||||
### 11.2 Local Folder
|
||||
|
||||
同一 session 的多个 editor view 共享一份:
|
||||
|
||||
- conflictDetectionKey
|
||||
- 外部变更状态
|
||||
|
||||
如果命中文件外部更新:
|
||||
|
||||
- session clean:自动拉新 aggregate,并广播到两个 view
|
||||
- session dirty:session 标记 `external-change-conflict`,两个 view 统一显示冲突状态
|
||||
|
||||
注意这里的冲突判断应该只做一次,不能让左右各自判断、各自报警。
|
||||
|
||||
## 12. 对现有代码的落点建议
|
||||
|
||||
### 12.1 `layout.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- 右键菜单事件继续发出
|
||||
- 新增 pane layout 宿主 HTML
|
||||
- 新增分割线、关闭按钮和右侧容器
|
||||
- 新增 pane 级 URL 恢复逻辑
|
||||
|
||||
不负责:
|
||||
|
||||
- 文档 session 真相
|
||||
|
||||
### 12.2 `web_shell.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- 文档 bootstrap 适配为可挂载到多个 pane
|
||||
- 抽出 session / view 绑定逻辑
|
||||
- 保存、replaceContent、外部变更处理改为 session 级而不是单 view 级
|
||||
|
||||
### 12.3 `local_folder_events.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- 继续输出文件变化事件
|
||||
- 后续收口为可复用 watcher registry
|
||||
|
||||
### 12.4 `styles.rs`
|
||||
|
||||
负责:
|
||||
|
||||
- 双栏布局
|
||||
- 右侧 pane 样式
|
||||
- 分割线和关闭按钮
|
||||
- 窄屏下的降级显示
|
||||
|
||||
## 13. 分阶段执行
|
||||
|
||||
### Phase 1:固定双栏宿主
|
||||
|
||||
- 增加 `primary pane + secondary pane`
|
||||
- 右侧可打开、替换、关闭
|
||||
- 左侧切页不影响右侧
|
||||
- URL 可恢复 secondary 状态
|
||||
|
||||
### Phase 2:文档 session 共享
|
||||
|
||||
- 同一文档双开挂到同一 session
|
||||
- 左右输入即时同步
|
||||
- 保存队列收口为 session 单例
|
||||
|
||||
### Phase 3:watcher / EventSource 复用
|
||||
|
||||
- tree SSE 明确归属 workspace shell
|
||||
- local-folder EventSource 按 rootUri 复用
|
||||
- Rust watcher 按 rootUri 复用
|
||||
|
||||
### Phase 4:回归与体验补齐
|
||||
|
||||
- 关闭、替换、reload 恢复
|
||||
- 同文档双开冲突链路
|
||||
- local_folder / convex_workspace 双链路验证
|
||||
|
||||
## 14. 测试计划
|
||||
|
||||
### 14.1 Rust / 服务侧
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- secondary URL 参数解析
|
||||
- local-folder watcher registry 复用
|
||||
- 相同 rootUri 多订阅者不会重复建 watcher
|
||||
|
||||
### 14.2 浏览器 smoke
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- 右键“在右侧边栏打开”后出现右侧 pane
|
||||
- 左侧切页后右侧保持不变
|
||||
- 同一文档左右双开时,左改右同步、右改左同步
|
||||
- 右侧关闭后 URL secondary 参数清理
|
||||
- 本地 Markdown 双开时,不重复建立 rootUri 级事件流
|
||||
|
||||
### 14.3 回归边界
|
||||
|
||||
- 不破坏当前单页文档路径
|
||||
- 不破坏 Page Aggregate 主链
|
||||
- 不让 watcher 数和 pane 数线性相关
|
||||
|
||||
## 15. 详细 checklist
|
||||
|
||||
### 15.1 范围冻结与基线取证
|
||||
|
||||
- [x] 固定第一阶段只做 `primary pane + secondary pane`,不引入 tabs / 多 group。
|
||||
- [x] 固定右侧 pane 默认可编辑,不做只读 preview 过渡态。
|
||||
- [x] 固定左侧普通导航只驱动 `primary pane`。
|
||||
- [x] 固定左侧切页后 `secondary pane` 保持不变。
|
||||
- [x] 固定同一文档允许左右双开。
|
||||
- [x] 固定同一文档双开时只同步内容,不同步 selection / scroll。
|
||||
- [x] 记录当前本地“在右侧边栏打开”仅发事件、无容器承接的基线证据。
|
||||
- [x] 补一条 design 说明:第一阶段不扩到跨窗口同步,只覆盖单个浏览器窗口内双 pane。
|
||||
|
||||
> 当前阶段说明:
|
||||
> - 第一阶段正式范围固定为“单个浏览器窗口内的 `primary pane + secondary pane` 双栏文档宿主”
|
||||
> - 不扩到 tabs / groups / 跨窗口共享 session;跨窗口同步仍留给后续单独方案
|
||||
> - 当前实现里 secondary pane 默认就是可编辑 editor,不存在只读 preview 过渡态
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 已覆盖“同一文档左右双开”和“双 editor 都可编辑”两条事实
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 还覆盖了:打开 `primary=README.md / secondary=side.md` 后,左侧普通导航切到 `third.md` 只会更新 primary,secondary 仍保持 `side.md`
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 还覆盖了:
|
||||
> - fixture 同文档双开时,primary 输入后焦点仍留在 primary,secondary 输入后焦点仍留在 secondary
|
||||
> - local folder 外部更新命中 `replaceContent` 后,共享页面滚动位置保持不变
|
||||
> - 浏览器天然只有 1 份全局 DOM selection;当前验收以“对侧同步不会抢走当前 pane 焦点/选择上下文”作为 selection 不串 pane 的标准
|
||||
> - 基线问题已记录:用户在最初验收阶段明确指出“在右侧边栏打开这个功能并没有实现”,对应证据为 `/mnt/Data1T/mnote/tmp/image copy 57.png`;当时表现为右侧打开链路没有真实宿主承接
|
||||
|
||||
### 15.2 URL 与路由参数
|
||||
|
||||
- [x] 为右侧 pane 增加 `secondaryDocumentId` 查询参数。
|
||||
- [x] 为本地 Markdown 右侧 pane 增加 `secondarySourceKind` 查询参数。
|
||||
- [x] 为本地 Markdown 右侧 pane 增加 `secondaryRootUri` 查询参数。
|
||||
- [x] 约定缺少 secondary 参数时,页面回退为单 pane。
|
||||
- [x] 约定 secondary 文档无效时,自动清理 secondary 参数并保留主 pane。
|
||||
- [x] 固定 URL 更新策略:打开右侧、替换右侧、关闭右侧都同步回写 URL。
|
||||
- [x] 补测试覆盖:reload 后按 URL 正确恢复双 pane。
|
||||
|
||||
### 15.3 Pane 宿主与布局容器
|
||||
|
||||
- [x] 在 `workspace shell` 内容区引入双 pane 宿主节点。
|
||||
- [x] 保留现有左侧树和顶部壳只渲染一份,不复制第二套外层壳。
|
||||
- [x] 增加右侧 pane 关闭按钮。
|
||||
- [x] 增加主次 pane 之间的分割线容器。
|
||||
- [x] 增加分割线拖拽后的宽度状态存储。
|
||||
- [x] 固定右侧 pane 最小宽度与默认宽度。
|
||||
- [x] 窄屏下定义降级行为,避免直接把正文压到不可用宽度。
|
||||
- [x] 补 smoke:打开右侧 pane 后 DOM 中出现稳定的 secondary 宿主标记。
|
||||
|
||||
### 15.4 Pane Manager
|
||||
|
||||
- [x] 新增 `PaneManager`,管理 `primary / secondary` 的打开状态。
|
||||
- [x] `PaneManager` 管理当前 active pane,但不管理文档内容真相。
|
||||
- [x] `PaneManager` 能处理“打开右侧”“替换右侧”“关闭右侧”三种动作。
|
||||
- [x] `PaneManager` 能处理从 URL 恢复双 pane。
|
||||
- [x] `PaneManager` 能处理 secondary 文档失效时的降级回退。
|
||||
- [x] 固定“普通树点击默认作用于 primary pane”的路由策略。
|
||||
- [x] 为后续 tabs / groups 预留最小扩展接口,但这一步不实现 tabs。
|
||||
|
||||
> 当前验证证据:
|
||||
> - runtime 已收口 `paneRouteConfig / secondaryQueryParamNames / paneQueryParams` 这组最小角色配置
|
||||
> - 当前仍只落到 `primary / secondary` 两个 role,没有引入 tabs / groups UI,但后续若扩展额外 pane role,查询参数映射不再散落在各处硬编码
|
||||
|
||||
### 15.5 Document Session Registry
|
||||
|
||||
- [x] 新增 `DocumentSessionRegistry`。
|
||||
- [x] 固定 session key 由 `sourceKind + workspaceId/rootUri + documentId` 组成。
|
||||
- [x] Convex workspace 文档和 local folder 文档都接入同一 registry 接口。
|
||||
- [x] 一个 session 可同时挂多个 editor view。
|
||||
- [x] 同一 session 只保留一份 aggregate / revision / conflictDetectionKey / dirty 状态。
|
||||
- [x] 同一 session 只保留一条保存队列。
|
||||
- [x] session 生命周期与 view 挂载数关联,最后一个 view 卸载后可延迟释放。
|
||||
- [x] 补测试:同 key 请求复用同一 session,不同 key 返回不同 session。
|
||||
|
||||
> 当前验证证据:
|
||||
> - runtime 已收口 `createEditorViewBinding / unmountEditorViewBinding / scheduleDocumentSessionRelease / releaseDocumentSession`
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 通过 debug 快照断言:
|
||||
> - fixture 同文档双开时 `sessionCount=1`、`viewCount=2`
|
||||
> - local folder 不同文档双开时 `sessionCount=2`
|
||||
> - 手工移除一个 pane 后,原 session 保持存活且 `viewCount=1`
|
||||
> - 手工移除最后一个 pane 并等待释放窗口后,`sessionCount=0`、`localFolderChannelCount=0`
|
||||
> - 由于 registry 位于浏览器端 inline runtime,这里的覆盖采用“浏览器自动化 + debug 快照断言”,不再额外补一层只验证字符串存在的伪单测
|
||||
|
||||
### 15.6 Editor View Binding
|
||||
|
||||
- [x] 把当前单编辑器挂载逻辑抽成可复用 `EditorViewBinding`。
|
||||
- [x] 一个 pane 对应一个 view binding,而不是一个完整独立 shell。
|
||||
- [x] view binding 初始化时从 session 读取当前内容。
|
||||
- [x] view binding 的本地输入统一回传 session,而不是直接各自保存。
|
||||
- [x] view binding 接收 session 广播时能更新本 view 内容。
|
||||
- [x] 来自同 session 的远端更新不能把当前 view 的 selection 直接重置为对侧状态。
|
||||
- [x] 补 smoke:同文档双开时左右都出现真实可编辑 editor。
|
||||
|
||||
> 当前验证证据:
|
||||
> - runtime 已显式引入 `createEditorViewBinding()` 与 `unmountEditorViewBinding()`,每个 pane 挂一个 binding,binding 自己持有 mountId、事件监听和 observer 清理逻辑
|
||||
> - `mountPane()` 现在只负责取 runtime / session、创建 binding、执行 mount,不再把所有逻辑堆在一个匿名挂载过程里
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 已自动断言:primary 输入后焦点仍留在 primary,secondary 输入后焦点仍留在 secondary
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 已自动断言:local folder 外部更新触发 `replaceContent` 后,共享页面滚动位置不被重置
|
||||
|
||||
### 15.7 同文档双开即时同步
|
||||
|
||||
- [x] 在 session 内建立“单写入源 + 多 view 广播”机制。
|
||||
- [x] 左侧编辑时,右侧无需 reload 即更新。
|
||||
- [x] 右侧编辑时,左侧无需 reload 即更新。
|
||||
- [x] 同步链路不依赖保存成功后重新拉 aggregate。
|
||||
- [x] 同步链路要避免 view A 更新触发 view B,再回写成无限循环。
|
||||
- [x] 固定本地广播与服务端回包的优先级规则,避免旧回包覆盖较新内存态。
|
||||
- [x] 补 smoke:左改右同步、右改左同步都能自动断言。
|
||||
|
||||
### 15.8 保存队列与共享状态
|
||||
|
||||
- [x] 保存状态提升到 session 级,而不是 view 级。
|
||||
- [x] 同一 session 下多个 view 共享 `dirty / saving / saved / error` 状态。
|
||||
- [x] 同一 session 只允许一个防抖保存队列处于活动中。
|
||||
- [x] 服务端保存成功后统一更新所有 view 的 revision。
|
||||
- [x] local folder 保存成功后统一更新所有 view 的 conflictDetectionKey。
|
||||
- [x] 任一 view 触发保存失败时,错误状态广播到同 session 其他 view。
|
||||
- [x] 补测试:同 session 双 view 输入后只触发一次保存请求。
|
||||
|
||||
### 15.9 Tree Realtime 复用
|
||||
|
||||
- [x] 固定 tree SSE 属于 `workspace shell`,不属于 pane。
|
||||
- [x] 打开 secondary pane 后,不创建第二条 tree realtime EventSource。
|
||||
- [x] 双 pane 状态下,页面标题、树 active 态、投影刷新仍由共享 tree 链路驱动。
|
||||
- [x] 若后续 pane 内导航触发页面切换,仍复用同一条 tree SSE。
|
||||
- [x] 补浏览器验证:双 pane 状态下 tree 事件流连接数保持为 1。
|
||||
|
||||
> 当前验证证据:
|
||||
> - tree live bootstrap 与 `TREE_LIVE_CONTROLLER_JS` 挂在 `PageLayout` 的 workspace shell 上,不在 `DocumentPane` 内重复注入
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 在 fixture 双 pane 页面里断言:
|
||||
> - 首次进入只出现 1 条 `GET /api/tree/events`
|
||||
> - reload 后当前页面仍只出现 1 条 `GET /api/tree/events`
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 在 local folder 双 pane 页面里断言:
|
||||
> - 左侧普通导航切到 `Local Third` 后,共享 topbar 标题同步切到 `Local Third`
|
||||
> - 共享侧栏 active / selected 状态同步落到 `Local Third`
|
||||
> - secondary pane 仍保持原来的 `Local Side` 文档,不复制第二套页面壳
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 在 fixture 双 pane 页面里还断言:
|
||||
> - 通过共享 sidebar 导航切到 `Fixture Other` 后,当前页面只建立 1 条新的 `GET /api/tree/events`
|
||||
> - 导航后 `secondaryDocumentId` 仍保持原值,没有因为 primary 切页而再额外起第二条 tree SSE
|
||||
|
||||
### 15.10 Frontend Local Folder Event Registry
|
||||
|
||||
- [x] 新增前端 `LocalFolderEventRegistry`。
|
||||
- [x] 按 `rootUri` 复用 `/api/local-folder/events` EventSource。
|
||||
- [x] 同一 `rootUri` 下两个不同 Markdown 文档复用同一条 EventSource。
|
||||
- [x] 同一 Markdown 左右双开复用同一条 EventSource。
|
||||
- [x] 文件变化事件到达后,先路由到 session,再决定是否刷新 view。
|
||||
- [x] clean session 自动同步;dirty session 统一进入冲突状态。
|
||||
- [x] 补 smoke:双 pane 本地 Markdown 场景下,不重复建立 rootUri 级事件流。
|
||||
|
||||
> 当前手工验证证据:
|
||||
> - `local_folder` 同文档左右双开时,浏览器侧只建立 1 条 `GET /api/local-folder/events?rootUri=...`
|
||||
> - 主 pane 输入后,secondary pane 无需 reload 即同步
|
||||
> - 单轮输入后只出现 1 次 `POST /api/documents/save`
|
||||
> - local folder watcher 回流已压缩到 1 次 `GET /api/page-aggregate/...`,不再出现重复回读
|
||||
> - `task165-rust-web-dual-pane-smoke.js` 还自动断言了:
|
||||
> - 同 rootUri 同文档双开只建立 1 条 `GET /api/local-folder/events`
|
||||
> - 同 rootUri 不同文档双开也只建立 1 条 `GET /api/local-folder/events`
|
||||
> - debug 快照下 `localFolderChannelCount=1`
|
||||
|
||||
### 15.11 Backend Local Folder Watcher Registry
|
||||
|
||||
- [x] Rust 侧引入或收口 `LocalFolderWatcherRegistry`。
|
||||
- [x] 按 `rootUri` 维护单例 `notify` watcher。
|
||||
- [x] 多个 SSE 订阅者共享同一个 watcher 输出。
|
||||
- [x] watcher 只上报有效 Create / Remove / 真正 Modify 事件,不把 access 噪音当作变更。
|
||||
- [x] SSE 订阅者断开后,registry 能在安全时机回收无引用 watcher。
|
||||
- [x] 补 Rust 单测:相同 `rootUri` 多订阅不会重复创建 watcher。
|
||||
- [x] 补 Rust 单测:不同 `rootUri` 会创建独立 watcher。
|
||||
|
||||
> 当前验证证据:
|
||||
> - `cargo test -p mnote-web local_folder_watcher_registry -- --nocapture` 通过
|
||||
> - `same_root_subscribers_share_single_watcher` 覆盖“同 root 不重复建 watcher”
|
||||
> - `different_roots_create_independent_watchers` 覆盖“不同 root 分别建 watcher”
|
||||
> - 订阅 drop 后 `active_watcher_count()` 回落到 `0`,覆盖 watcher 回收路径
|
||||
|
||||
### 15.12 外部变更与冲突处理
|
||||
|
||||
- [x] Convex 文档沿用共享 revision 判断,不为每个 view 单独做冲突模型。
|
||||
- [x] local folder 文档沿用共享 conflictDetectionKey 判断,不为每个 view 单独判断。
|
||||
- [x] 同一 session clean 时命中外部文件变化,两个 view 同时自动同步。
|
||||
- [x] 同一 session dirty 时命中外部文件变化,两个 view 同时显示冲突状态。
|
||||
- [x] 冲突提示提升到 session 级,避免左右状态不一致。
|
||||
- [x] 补 smoke:dirty 状态下修改磁盘文件,左右两侧都进入统一冲突状态。
|
||||
|
||||
> 当前验证证据:
|
||||
> - clean 状态下外部改写本地 Markdown 后,左右 pane 都会同步更新正文,并共同进入 `synced-external-change`
|
||||
> - session 级 `conflictDetectionKey` 已前移为共享字段;Rust 侧 local Markdown `conflictDetectionKey` 已加固为 `mtime + len + content hash`
|
||||
> - `cargo test -p mnote-web local_folder_source -- --nocapture` 通过,包含 `local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size`
|
||||
> - dirty 状态下通过独立外部保存请求写盘同一 local Markdown 文件时,左右 pane 会共同进入 `external-change-conflict`
|
||||
> - 冲突提示文案在左右 pane 一致:`本地 Markdown 文件已在外部更新,请刷新或保存前先处理冲突`
|
||||
|
||||
### 15.13 标题、Page Aggregate 与页面壳一致性
|
||||
|
||||
- [x] 主标题控制器在双 pane 下不要错误写到另一个 pane。
|
||||
- [x] 需要明确每个 pane 的标题显示与编辑归属。
|
||||
- [x] 若同一文档双开,标题修改后两个 pane 标题都同步。
|
||||
- [x] `PageAggregate` 拉取逻辑要支持 secondary 文档,不复制整页壳。
|
||||
- [x] 不引入第二份页面真相,不在 pane 层重新拼文档对象。
|
||||
|
||||
> 当前验证证据:
|
||||
> - 同一 local Markdown 左右双开时,在 primary pane 改标题并 blur 后:
|
||||
> - secondary pane 标题输入同步为同一标题
|
||||
> - 顶部 breadcrumb 标题同步
|
||||
> - `document.title` 同步
|
||||
> - 磁盘文件写入 `title:` frontmatter
|
||||
> - primary/secondary 打开不同 local Markdown 时,在 primary pane 改标题后:
|
||||
> - secondary pane 标题保持原文档标题,不被串改
|
||||
> - 只有 primary 对应文件的 `title:` frontmatter 被改写
|
||||
> - primary=`README.md`、secondary=`side-one.md` 时,secondary pane 渲染 `Side One / 右一正文`
|
||||
> - 通过 `tree.page.open-right` 切换 secondary 到 `side-two.md` 后,secondary pane 渲染 `Side Two / 右二正文`
|
||||
> - 切换前后页面都只保留 1 份侧栏/顶部壳,secondary editor root 数量保持为 1
|
||||
> - `build_document_panes_bootstrap_json()` 直接把 primary / secondary 的 `PageAggregate + bootstrap` 序列化进同一份 `panes` contract,pane runtime 只消费已有 aggregate,不再在 pane 层拼第二份页面对象
|
||||
> - `DocumentPage / DocumentPane` 只消费 route 已经解出的 `title / document_id / pageOptions / pageSubtree` 等字段;secondary 只是同一 workspace shell 内的第二个 pane,不复制第二套 workspace 壳
|
||||
|
||||
### 15.14 交互与体验收尾
|
||||
|
||||
- [x] 右侧 pane 支持键盘关闭或显式关闭按钮关闭。
|
||||
- [x] 右侧 pane 替换文档时,旧 session view 正确卸载。
|
||||
- [x] 关闭右侧 pane 后回到单 pane 布局,不残留 secondary 宿主空壳。
|
||||
- [x] reload 后能恢复上次 secondary 打开的文档。
|
||||
- [x] 本地 Markdown 与 Convex 页面两条链都通过一次手工 smoke。
|
||||
|
||||
> 当前验证证据:
|
||||
> - 带 `secondaryDocumentId / secondarySourceKind / secondaryRootUri` 参数的 local Markdown 双 pane URL 在 reload 后仍恢复左右两个 pane
|
||||
> - 点击右侧 pane 关闭按钮后,URL 中 secondary 参数被清理,页面回落为单 pane,快照中不再出现 secondary 文档区
|
||||
> - 本地 Markdown 链:同文档左右双开时,左右编辑器都可输入,左改右同步、右改左同步、单轮输入只出现 1 次保存请求、同 rootUri 只建立 1 条 local-folder EventSource
|
||||
> - Convex / fixture 链:补齐 `MNOTE_WEB_QUERY_FIXTURES_JSON` + `MNOTE_WEB_MUTATION_FIXTURES_JSON` 后,双 pane 页面可正常输入保存,`POST /api/documents/save` 返回 `200`,状态进入 `saved`
|
||||
|
||||
### 15.15 验证命令与回归清单
|
||||
|
||||
- [x] 右侧 pane 宿主已完成一轮手工浏览器 smoke。
|
||||
- [x] 右侧 pane 宿主补自动化浏览器 smoke。
|
||||
- [x] 同文档双开同步已完成一轮手工浏览器 smoke。
|
||||
- [x] 同文档双开同步补自动断言 smoke。
|
||||
- [x] local folder 事件流复用已完成一轮手工浏览器 smoke。
|
||||
- [x] local folder 事件流复用补自动断言 smoke。
|
||||
- [x] watcher registry 复用已补 Rust 单测。
|
||||
- [x] URL 恢复与 secondary 参数清理已完成一轮手工 smoke。
|
||||
- [x] URL 恢复与 secondary 参数清理补自动化测试。
|
||||
- [x] 在 `3000` 常驻服务和临时端口两种模式下都验证一次,避免打到旧进程误判。
|
||||
|
||||
> 当前验证证据:
|
||||
> - 手工浏览器 smoke 已覆盖:
|
||||
> - secondary pane 真实渲染、可关闭、可 reload 恢复
|
||||
> - 同文档双开时左改右同步、右改左同步
|
||||
> - local folder 双 pane 下 rootUri 级事件流只建立 1 条,且单轮输入只出现 1 次保存请求
|
||||
> - `node scripts/task165-rust-web-dual-pane-smoke.js` 通过,自动覆盖:
|
||||
> - fixture 双 pane 宿主渲染
|
||||
> - 左改右同步、右改左同步
|
||||
> - 同 session 双 view 单轮输入只触发 1 次 `POST /api/documents/save`
|
||||
> - fixture 双 pane 只建立 1 条 `GET /api/tree/events`
|
||||
> - local folder 双 pane 只建立 1 条 `GET /api/local-folder/events`
|
||||
> - local folder 同 root 不同文档双开时仍只建立 1 条 `GET /api/local-folder/events`
|
||||
> - dual pane URL reload 恢复
|
||||
> - 关闭 secondary 后 URL 参数清理,reload 后保持单 pane
|
||||
> - 最后一个 view 卸载后延迟释放 session 与 local-folder channel
|
||||
> - `cargo test -p mnote-web local_folder_watcher_registry -- --nocapture` 通过,已覆盖:
|
||||
> - `same_root_subscribers_share_single_watcher`
|
||||
> - `different_roots_create_independent_watchers`
|
||||
> - `3000` 常驻服务当前仍指向旧进程:
|
||||
> - 只读 `curl` 核查 `http://127.0.0.1:3000/documents/local-md:README.md?...secondary...` 时,响应虽为 `200`,但 HTML 中缺少 `data-has-secondary-pane`、`__MNOTE_DOCUMENT_PANES_BOOTSTRAP__`、`localFolderEventRegistry`、`__mnoteDebugDocumentSessions` 等当前实现标记
|
||||
> - 当前 pid=`491543` 的 `3000` 进程不能代表本轮最新代码,因此这一条仍需在用户允许处理 `3000` 常驻服务后补正式验收
|
||||
|
||||
## 16. 完成判定
|
||||
|
||||
满足以下条件才算完成这一阶段:
|
||||
|
||||
- “在右侧边栏打开”不再只是菜单文案,而是真实右侧可编辑 pane
|
||||
- 左侧切页时右侧保持不变
|
||||
- 同一文档允许左右同时打开
|
||||
- 左右编辑能即时同步,不依赖 reload
|
||||
- tree SSE 仍为单条
|
||||
- local-folder EventSource / notify watcher 不按 pane 数重复创建
|
||||
|
||||
## 17. 一句话收口
|
||||
|
||||
这一稿的关键不是“把页面再多开一个”,而是:
|
||||
|
||||
> **把 `mnote-web` 的文档页从单编辑器壳升级为共享 transport + 共享 session + 多 pane view 的正式宿主。**
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::middleware::request_context::inject_request_context;
|
||||
use crate::routes::build_router;
|
||||
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
|
||||
use axum::Router;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
@@ -132,18 +133,24 @@ fn read_env_or_dotenv(key: &str) -> Option<String> {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppState {
|
||||
config: Arc<AppConfig>,
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
local_folder_watcher_registry: LocalFolderWatcherRegistry::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &AppConfig {
|
||||
self.config.as_ref()
|
||||
}
|
||||
|
||||
pub fn local_folder_watcher_registry(&self) -> &LocalFolderWatcherRegistry {
|
||||
&self.local_folder_watcher_registry
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod local_folder_watcher_registry;
|
||||
pub mod middleware;
|
||||
pub mod page_aggregate;
|
||||
pub mod routes;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
use notify::event::ModifyKind;
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalFolderWatcherRegistry {
|
||||
inner: Arc<LocalFolderWatcherRegistryInner>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LocalFolderWatcherRegistry {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LocalFolderWatcherRegistry")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(LocalFolderWatcherRegistryInner {
|
||||
entries: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(
|
||||
&self,
|
||||
canonical_root: &Path,
|
||||
) -> Result<LocalFolderWatcherSubscription, String> {
|
||||
let key = canonical_root_uri(canonical_root);
|
||||
let channel = self.inner.get_or_create_channel(&key, canonical_root)?;
|
||||
channel.subscriber_count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(LocalFolderWatcherSubscription {
|
||||
receiver: channel.sender.subscribe(),
|
||||
guard: LocalFolderWatcherSubscriptionGuard {
|
||||
registry: Arc::downgrade(&self.inner),
|
||||
key,
|
||||
channel,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn active_watcher_count(&self) -> usize {
|
||||
self.inner
|
||||
.entries
|
||||
.lock()
|
||||
.expect("registry lock")
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFolderWatcherRegistryInner {
|
||||
entries: Mutex<HashMap<String, Arc<LocalFolderWatchChannel>>>,
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherRegistryInner {
|
||||
fn get_or_create_channel(
|
||||
&self,
|
||||
key: &str,
|
||||
canonical_root: &Path,
|
||||
) -> Result<Arc<LocalFolderWatchChannel>, String> {
|
||||
if let Some(existing) = self.entries.lock().expect("registry lock").get(key).cloned() {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
let channel = Arc::new(LocalFolderWatchChannel::new(
|
||||
key.to_string(),
|
||||
spawn_local_folder_watcher(key, canonical_root.to_path_buf())?,
|
||||
));
|
||||
|
||||
let mut entries = self.entries.lock().expect("registry lock");
|
||||
if let Some(existing) = entries.get(key).cloned() {
|
||||
return Ok(existing);
|
||||
}
|
||||
entries.insert(key.to_string(), channel.clone());
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
fn remove_if_idle(&self, key: &str, channel: &Arc<LocalFolderWatchChannel>) {
|
||||
let mut entries = self.entries.lock().expect("registry lock");
|
||||
let should_remove = entries
|
||||
.get(key)
|
||||
.map(|current| {
|
||||
Arc::ptr_eq(current, channel)
|
||||
&& channel.subscriber_count.load(Ordering::SeqCst) == 0
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if should_remove {
|
||||
entries.remove(key);
|
||||
channel.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFolderWatchChannel {
|
||||
sender: broadcast::Sender<Value>,
|
||||
subscriber_count: AtomicUsize,
|
||||
shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
|
||||
}
|
||||
|
||||
impl LocalFolderWatchChannel {
|
||||
fn new(
|
||||
_root_uri: String,
|
||||
parts: (broadcast::Sender<Value>, oneshot::Sender<()>),
|
||||
) -> Self {
|
||||
Self {
|
||||
sender: parts.0,
|
||||
subscriber_count: AtomicUsize::new(0),
|
||||
shutdown_tx: Mutex::new(Some(parts.1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(&self) {
|
||||
if let Some(sender) = self.shutdown_tx.lock().expect("shutdown lock").take() {
|
||||
let _ = sender.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct LocalFolderWatcherSubscription {
|
||||
pub(crate) receiver: broadcast::Receiver<Value>,
|
||||
guard: LocalFolderWatcherSubscriptionGuard,
|
||||
}
|
||||
|
||||
impl LocalFolderWatcherSubscription {
|
||||
pub(crate) fn root_uri(&self) -> &str {
|
||||
self.guard.key.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
struct LocalFolderWatcherSubscriptionGuard {
|
||||
registry: Weak<LocalFolderWatcherRegistryInner>,
|
||||
key: String,
|
||||
channel: Arc<LocalFolderWatchChannel>,
|
||||
}
|
||||
|
||||
impl Drop for LocalFolderWatcherSubscriptionGuard {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.channel.subscriber_count.fetch_sub(1, Ordering::SeqCst);
|
||||
if previous != 1 {
|
||||
return;
|
||||
}
|
||||
if let Some(registry) = self.registry.upgrade() {
|
||||
registry.remove_if_idle(&self.key, &self.channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local_folder_watcher(
|
||||
root_uri: &str,
|
||||
canonical_root: PathBuf,
|
||||
) -> Result<(broadcast::Sender<Value>, oneshot::Sender<()>), String> {
|
||||
let (event_sender, mut event_receiver) = mpsc::unbounded_channel::<notify::Result<Event>>();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |result| {
|
||||
let _ = event_sender.send(result);
|
||||
},
|
||||
Config::default(),
|
||||
)
|
||||
.map_err(|error| format!("本地文件事件监听启动失败: {error}"))?;
|
||||
watcher
|
||||
.watch(&canonical_root, RecursiveMode::Recursive)
|
||||
.map_err(|error| format!("本地文件夹监听失败: {error}"))?;
|
||||
|
||||
let (sender, _) = broadcast::channel::<Value>(256);
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
|
||||
let sender_for_task = sender.clone();
|
||||
let root_uri_for_task = root_uri.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _watcher = watcher;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = &mut shutdown_rx => {
|
||||
return;
|
||||
}
|
||||
maybe_result = event_receiver.recv() => {
|
||||
let Some(result) = maybe_result else {
|
||||
return;
|
||||
};
|
||||
let Ok(event) = result else {
|
||||
continue;
|
||||
};
|
||||
if !should_emit_event_kind(&event.kind) {
|
||||
continue;
|
||||
}
|
||||
for path in event.paths {
|
||||
if !is_markdown_path(&path) {
|
||||
continue;
|
||||
}
|
||||
let Some(relative_path) = relative_path_string(&canonical_root, &path) else {
|
||||
continue;
|
||||
};
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri_for_task,
|
||||
"relativePath": relative_path,
|
||||
"documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)),
|
||||
"eventKind": format!("{:?}", event.kind),
|
||||
"revision": event_revision(&path),
|
||||
});
|
||||
let _ = sender_for_task.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((sender, shutdown_tx))
|
||||
}
|
||||
|
||||
fn canonical_root_uri(root: &Path) -> String {
|
||||
format!("file://{}", root.display())
|
||||
}
|
||||
|
||||
fn relative_path_string(root: &Path, path: &Path) -> Option<String> {
|
||||
path.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
|
||||
.filter(|relative| !relative.is_empty())
|
||||
}
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| {
|
||||
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn should_emit_event_kind(kind: &EventKind) -> bool {
|
||||
match kind {
|
||||
EventKind::Create(_) | EventKind::Remove(_) => true,
|
||||
EventKind::Modify(modify_kind) => matches!(
|
||||
modify_kind,
|
||||
ModifyKind::Any | ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Metadata(_)
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_local_id_segment(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
let character = *byte as char;
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
|
||||
encoded.push(character);
|
||||
} else {
|
||||
encoded.push('~');
|
||||
encoded.push_str(&format!("{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn event_revision(path: &Path) -> u128 {
|
||||
std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|metadata| metadata.modified().ok())
|
||||
.map(system_time_ms)
|
||||
.unwrap_or_else(|| system_time_ms(SystemTime::now()))
|
||||
}
|
||||
|
||||
fn system_time_ms(time: SystemTime) -> u128 {
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{should_emit_event_kind, LocalFolderWatcherRegistry};
|
||||
use notify::event::{AccessKind, CreateKind, DataChange, ModifyKind};
|
||||
use notify::EventKind;
|
||||
|
||||
fn test_root(name: &str) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"mnote-local-folder-watcher-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("create temp root");
|
||||
root
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn same_root_subscribers_share_single_watcher() {
|
||||
let registry = LocalFolderWatcherRegistry::new();
|
||||
let root = test_root("shared");
|
||||
|
||||
let first = registry.subscribe(&root).expect("first subscription");
|
||||
let second = registry.subscribe(&root).expect("second subscription");
|
||||
|
||||
assert_eq!(registry.active_watcher_count(), 1);
|
||||
|
||||
drop(first);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(registry.active_watcher_count(), 1);
|
||||
|
||||
drop(second);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(registry.active_watcher_count(), 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_roots_create_independent_watchers() {
|
||||
let registry = LocalFolderWatcherRegistry::new();
|
||||
let first_root = test_root("first");
|
||||
let second_root = test_root("second");
|
||||
|
||||
let first = registry.subscribe(&first_root).expect("first root subscription");
|
||||
let second = registry
|
||||
.subscribe(&second_root)
|
||||
.expect("second root subscription");
|
||||
|
||||
assert_eq!(registry.active_watcher_count(), 2);
|
||||
|
||||
drop(first);
|
||||
drop(second);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(registry.active_watcher_count(), 0);
|
||||
|
||||
let _ = std::fs::remove_dir_all(first_root);
|
||||
let _ = std::fs::remove_dir_all(second_root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_kind_filter_ignores_access_events() {
|
||||
assert!(should_emit_event_kind(&EventKind::Create(CreateKind::File)));
|
||||
assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data(
|
||||
DataChange::Content,
|
||||
))));
|
||||
assert!(!should_emit_event_kind(&EventKind::Access(AccessKind::Read)));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::local_folder_source::decode_local_id_segment;
|
||||
use axum::extract::{Extension, Query};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||
use futures_util::stream;
|
||||
use notify::event::ModifyKind;
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::convert::Infallible;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -22,6 +21,7 @@ pub struct LocalFolderEventsQuery {
|
||||
}
|
||||
|
||||
pub async fn local_folder_events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Query(query): Query<LocalFolderEventsQuery>,
|
||||
) -> Result<
|
||||
@@ -43,78 +43,49 @@ pub async fn local_folder_events(
|
||||
.document_id
|
||||
.as_deref()
|
||||
.and_then(local_markdown_relative_path_from_document_id);
|
||||
|
||||
let (sender, receiver) = mpsc::unbounded_channel::<Value>();
|
||||
let (watch_sender, mut watch_receiver) =
|
||||
mpsc::unbounded_channel::<notify::Result<Event>>();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |result| {
|
||||
let _ = watch_sender.send(result);
|
||||
},
|
||||
Config::default(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("本地文件事件监听启动失败: {error}")).with_context(&context)
|
||||
})?;
|
||||
watcher
|
||||
.watch(&canonical_root, RecursiveMode::Recursive)
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!("本地文件夹监听失败: {error}")).with_context(&context)
|
||||
})?;
|
||||
|
||||
let root_for_task = canonical_root.clone();
|
||||
let root_uri = query.root_uri.clone();
|
||||
tokio::spawn(async move {
|
||||
let _watcher = watcher;
|
||||
while let Some(result) = watch_receiver.recv().await {
|
||||
let Ok(event) = result else {
|
||||
continue;
|
||||
};
|
||||
if !should_emit_event_kind(&event.kind) {
|
||||
continue;
|
||||
}
|
||||
for path in event.paths {
|
||||
if !is_markdown_path(&path) {
|
||||
continue;
|
||||
}
|
||||
let Some(relative_path) = relative_path_string(&root_for_task, &path) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(expected) = document_relative_path.as_deref() {
|
||||
if expected != relative_path {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let payload = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": root_uri,
|
||||
"relativePath": relative_path,
|
||||
"documentId": format!("local-md:{}", encode_local_id_segment(&relative_path)),
|
||||
"eventKind": format!("{:?}", event.kind),
|
||||
"revision": event_revision(&path),
|
||||
});
|
||||
if sender.send(payload).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let subscription = state
|
||||
.local_folder_watcher_registry()
|
||||
.subscribe(&canonical_root)
|
||||
.map_err(|error| WebError::internal(error).with_context(&context))?;
|
||||
|
||||
let initial = json!({
|
||||
"sourceKind": "local_folder",
|
||||
"rootUri": query.root_uri,
|
||||
"rootUri": subscription.root_uri(),
|
||||
"documentId": query.document_id,
|
||||
"revision": system_time_ms(SystemTime::now()),
|
||||
});
|
||||
let stream = stream::unfold((Some(initial), receiver), |(initial, mut receiver)| async move {
|
||||
let stream = stream::unfold(
|
||||
(Some(initial), subscription, document_relative_path),
|
||||
|(initial, mut subscription, document_relative_path)| async move {
|
||||
if let Some(payload) = initial {
|
||||
return Some((Ok(stream_event("ready", &payload)), (None, receiver)));
|
||||
return Some((
|
||||
Ok(stream_event("ready", &payload)),
|
||||
(None, subscription, document_relative_path),
|
||||
));
|
||||
}
|
||||
receiver
|
||||
.recv()
|
||||
.await
|
||||
.map(|payload| (Ok(stream_event("change", &payload)), (None, receiver)))
|
||||
});
|
||||
loop {
|
||||
match subscription.receiver.recv().await {
|
||||
Ok(payload) => {
|
||||
if let Some(expected) = document_relative_path.as_deref() {
|
||||
let relative_path = payload
|
||||
.get("relativePath")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if expected != relative_path {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Some((
|
||||
Ok(stream_event("change", &payload)),
|
||||
(None, subscription, document_relative_path),
|
||||
));
|
||||
}
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => return None,
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
|
||||
@@ -151,55 +122,6 @@ fn local_markdown_relative_path_from_document_id(document_id: &str) -> Option<St
|
||||
decode_local_id_segment(encoded).ok()
|
||||
}
|
||||
|
||||
fn relative_path_string(root: &Path, path: &Path) -> Option<String> {
|
||||
path.strip_prefix(root)
|
||||
.ok()
|
||||
.map(|relative| relative.to_string_lossy().replace('\\', "/"))
|
||||
.filter(|relative| !relative.is_empty())
|
||||
}
|
||||
|
||||
fn is_markdown_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| {
|
||||
extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn should_emit_event_kind(kind: &EventKind) -> bool {
|
||||
match kind {
|
||||
EventKind::Create(_) | EventKind::Remove(_) => true,
|
||||
EventKind::Modify(modify_kind) => matches!(
|
||||
modify_kind,
|
||||
ModifyKind::Any | ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Metadata(_)
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_local_id_segment(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
let character = *byte as char;
|
||||
if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
|
||||
encoded.push(character);
|
||||
} else {
|
||||
encoded.push('~');
|
||||
encoded.push_str(&format!("{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn event_revision(path: &Path) -> u128 {
|
||||
std::fs::metadata(path)
|
||||
.ok()
|
||||
.and_then(|metadata| metadata.modified().ok())
|
||||
.map(system_time_ms)
|
||||
.unwrap_or_else(|| system_time_ms(SystemTime::now()))
|
||||
}
|
||||
|
||||
fn system_time_ms(time: SystemTime) -> u128 {
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
@@ -237,20 +159,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_path_filter_accepts_markdown_files_only() {
|
||||
assert!(is_markdown_path(Path::new("README.md")));
|
||||
assert!(is_markdown_path(Path::new("README.markdown")));
|
||||
assert!(!is_markdown_path(Path::new("image.png")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_kind_filter_ignores_access_events() {
|
||||
assert!(should_emit_event_kind(&EventKind::Create(notify::event::CreateKind::File)));
|
||||
assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data(
|
||||
notify::event::DataChange::Content,
|
||||
))));
|
||||
assert!(!should_emit_event_kind(&EventKind::Access(
|
||||
notify::event::AccessKind::Read,
|
||||
)));
|
||||
fn parse_file_root_uri_requires_file_scheme() {
|
||||
assert!(parse_file_root_uri("file:///tmp/example").is_ok());
|
||||
assert!(parse_file_root_uri("/tmp/example").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2655,6 +2655,15 @@ fn local_markdown_conflict_detection_key(
|
||||
document_id: &str,
|
||||
markdown_path: &Path,
|
||||
) -> Result<String, WebError> {
|
||||
let content = fs::read(markdown_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_markdown_read_failed",
|
||||
format!(
|
||||
"无法读取本地 Markdown 文件 {}: {error}",
|
||||
markdown_path.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let meta = fs::metadata(markdown_path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"local_markdown_stat_failed",
|
||||
@@ -2665,8 +2674,11 @@ fn local_markdown_conflict_detection_key(
|
||||
)
|
||||
})?;
|
||||
let modified_ms = system_time_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
let content_hash = hasher.finish();
|
||||
Ok(format!(
|
||||
"local-md:{document_id}:{modified_ms}:{}",
|
||||
"local-md:{document_id}:{modified_ms}:{}:{content_hash:016x}",
|
||||
meta.len()
|
||||
))
|
||||
}
|
||||
@@ -2965,6 +2977,25 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_conflict_detection_key_changes_when_content_changes_with_same_size() {
|
||||
let root = temp_root("mnote-local-conflict-key-content");
|
||||
let file = root.join("README.md");
|
||||
std::fs::write(&file, "aaaa\n").expect("write first content");
|
||||
|
||||
let first = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
|
||||
.expect("first conflict key");
|
||||
|
||||
std::fs::write(&file, "bbbb\n").expect("write second content");
|
||||
|
||||
let second = super::local_markdown_conflict_detection_key("local-md:README.md", &file)
|
||||
.expect("second conflict key");
|
||||
|
||||
assert_ne!(first, second);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_markdown_parser_covers_basic_blocks_and_attachment_refs() {
|
||||
let blocks = crate::routes::local_markdown_parser::markdown_to_blocks(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,142 @@ use crate::ssr::pages::layout::PageLayout;
|
||||
use leptos::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DocumentPaneViewModel {
|
||||
pub pane_role: &'static str,
|
||||
pub title: String,
|
||||
pub document_id: String,
|
||||
pub workspace_id: String,
|
||||
pub page_wide_layout: bool,
|
||||
pub page_small_text: bool,
|
||||
pub page_layout_density: String,
|
||||
pub page_font: String,
|
||||
pub page_show_heading_numbers: bool,
|
||||
pub has_page_subtree: bool,
|
||||
pub primary_legacy_ids: bool,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
|
||||
let pane_hidden = !model.visible;
|
||||
let pane_role = model.pane_role.to_string();
|
||||
let pane_role_attr = pane_role.clone();
|
||||
let pane_role_attr_two = pane_role.clone();
|
||||
let pane_role_attr_three = pane_role.clone();
|
||||
let pane_role_attr_four = pane_role.clone();
|
||||
let pane_role_attr_five = pane_role.clone();
|
||||
let pane_label = if model.pane_role == "secondary" {
|
||||
"右侧文档"
|
||||
} else {
|
||||
"主文档"
|
||||
};
|
||||
let title_input_id = if model.primary_legacy_ids {
|
||||
Some("mnote-page-title-input".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let editor_island_id = if model.primary_legacy_ids {
|
||||
Some("mnote-editor-island".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let editor_root_id = if model.primary_legacy_ids {
|
||||
Some("mnote-leptos-tiptap-island-editor-root".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
view! {
|
||||
<section
|
||||
class="document-pane"
|
||||
data-document-pane="true"
|
||||
data-pane-role={pane_role_attr}
|
||||
data-pane-document-id={model.document_id.clone()}
|
||||
data-pane-workspace-id={model.workspace_id.clone()}
|
||||
data-pane-visible={model.visible.to_string()}
|
||||
aria-label={pane_label}
|
||||
hidden={pane_hidden}
|
||||
>
|
||||
<main
|
||||
class="document-shell"
|
||||
data-editor-host="leptos_tiptap_island"
|
||||
data-document-id={model.document_id.clone()}
|
||||
data-workspace-id={model.workspace_id.clone()}
|
||||
data-pane-role={pane_role_attr_two}
|
||||
data-page-wide-layout={model.page_wide_layout.to_string()}
|
||||
data-page-small-text={model.page_small_text.to_string()}
|
||||
data-layout-density={model.page_layout_density.clone()}
|
||||
data-page-font={model.page_font.clone()}
|
||||
data-page-show-heading-numbers={model.page_show_heading_numbers.to_string()}
|
||||
>
|
||||
<header class="document-shell-header">
|
||||
<div class="document-page-icon" aria-hidden="true">
|
||||
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
|
||||
</div>
|
||||
<div class="document-pane-header-row">
|
||||
<h1 class="document-title-heading">
|
||||
<textarea
|
||||
id={title_input_id}
|
||||
class="document-title-input"
|
||||
aria-label="页面标题"
|
||||
data-page-title-input="true"
|
||||
data-document-id={model.document_id.clone()}
|
||||
data-workspace-id={model.workspace_id.clone()}
|
||||
data-pane-role={pane_role_attr_three}
|
||||
data-title-endpoint="/api/documents/title"
|
||||
rows="1"
|
||||
>{model.title.clone()}</textarea>
|
||||
</h1>
|
||||
<Show when={move || model.pane_role == "secondary"}>
|
||||
<button
|
||||
type="button"
|
||||
class="document-pane-close"
|
||||
data-mnote-pane-close="secondary"
|
||||
aria-label="关闭右侧文档"
|
||||
title="关闭右侧文档"
|
||||
>
|
||||
<span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="document-shell-meta" aria-label="页面元信息">
|
||||
<span data-page-title-current="true">{model.title.clone()}</span>
|
||||
</div>
|
||||
</header>
|
||||
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
|
||||
<section
|
||||
data-testid="mnote-page-subtree"
|
||||
data-page-tree-source="page_aggregate.tree.pageSubtree"
|
||||
data-page-subtree-present={model.has_page_subtree.to_string()}
|
||||
data-pane-role={pane_role_attr_four}
|
||||
></section>
|
||||
<section
|
||||
id={editor_island_id}
|
||||
data-editor-host="leptos_tiptap_island"
|
||||
data-pane-role={pane_role_attr_five}
|
||||
>
|
||||
<div
|
||||
id={editor_root_id}
|
||||
data-testid="mnote-leptos-tiptap-island-editor-root"
|
||||
data-editor-host-kind="leptos_tiptap_island"
|
||||
data-runtime-editor-status="booting"
|
||||
data-pane-role={pane_role.clone()}
|
||||
></div>
|
||||
<div
|
||||
class="sr-only"
|
||||
data-editor-host-observability="rust-web-inline-island"
|
||||
data-editor-host-active="leptos_tiptap_island"
|
||||
data-editor-host-requested="leptos_tiptap_island"
|
||||
data-editor-host-status="booting"
|
||||
data-pane-role={pane_role}
|
||||
></div>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
/// MNOTE 文档页面
|
||||
///
|
||||
/// 渲染文档编辑器的 SSR 壳结构:
|
||||
@@ -37,6 +173,21 @@ pub fn DocumentPage(
|
||||
/// Page Aggregate 页面选项 JSON(可选)
|
||||
#[prop(optional)]
|
||||
page_options_json: Option<String>,
|
||||
/// 右侧文档标题(可选)
|
||||
#[prop(optional)]
|
||||
secondary_title: String,
|
||||
/// 右侧文档 id(可选)
|
||||
#[prop(optional)]
|
||||
secondary_document_id: String,
|
||||
/// 右侧 workspace id(可选)
|
||||
#[prop(optional)]
|
||||
secondary_workspace_id: String,
|
||||
/// 右侧 Page Aggregate 子树 JSON(可选)
|
||||
#[prop(optional)]
|
||||
secondary_page_subtree_json: String,
|
||||
/// 右侧页面选项 JSON(可选)
|
||||
#[prop(optional)]
|
||||
secondary_page_options_json: String,
|
||||
) -> impl IntoView {
|
||||
let has_page_subtree = page_subtree_json
|
||||
.as_deref()
|
||||
@@ -71,62 +222,86 @@ pub fn DocumentPage(
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
let page_show_heading_numbers = false;
|
||||
let secondary_has_page_subtree = Some(secondary_page_subtree_json.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "null")
|
||||
.is_some();
|
||||
let secondary_page_options = Some(secondary_page_options_json.as_str())
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let secondary_page_wide_layout = secondary_page_options
|
||||
.get("wideLayout")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let secondary_page_small_text = secondary_page_options
|
||||
.get("smallText")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let secondary_page_layout_density = secondary_page_options
|
||||
.get("layoutDensity")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("normal")
|
||||
.to_string();
|
||||
let secondary_page_font = secondary_page_options
|
||||
.get("pageFont")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("default")
|
||||
.to_string();
|
||||
let secondary_page_show_heading_numbers = false;
|
||||
let secondary_visible = Some(secondary_document_id.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some();
|
||||
let primary_model = DocumentPaneViewModel {
|
||||
pane_role: "primary",
|
||||
title: title.clone(),
|
||||
document_id: document_id.clone(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
page_wide_layout,
|
||||
page_small_text,
|
||||
page_layout_density: page_layout_density.clone(),
|
||||
page_font: page_font.clone(),
|
||||
page_show_heading_numbers,
|
||||
has_page_subtree,
|
||||
primary_legacy_ids: true,
|
||||
visible: true,
|
||||
};
|
||||
let secondary_model = DocumentPaneViewModel {
|
||||
pane_role: "secondary",
|
||||
title: secondary_title,
|
||||
document_id: secondary_document_id,
|
||||
workspace_id: if secondary_workspace_id.trim().is_empty() {
|
||||
workspace_id.clone()
|
||||
} else {
|
||||
secondary_workspace_id
|
||||
},
|
||||
page_wide_layout: secondary_page_wide_layout,
|
||||
page_small_text: secondary_page_small_text,
|
||||
page_layout_density: secondary_page_layout_density,
|
||||
page_font: secondary_page_font,
|
||||
page_show_heading_numbers: secondary_page_show_heading_numbers,
|
||||
has_page_subtree: secondary_has_page_subtree,
|
||||
primary_legacy_ids: false,
|
||||
visible: secondary_visible,
|
||||
};
|
||||
view! {
|
||||
<PageLayout current_nav="documents" sidebar_tree_html={sidebar_tree_html.unwrap_or_default()} workspace_name={workspace_name.unwrap_or_default()} workspace_sidebar_html={workspace_sidebar_html.unwrap_or_default()} topbar_title={title.clone()}>
|
||||
<main
|
||||
class="document-shell"
|
||||
data-editor-host="leptos_tiptap_island"
|
||||
data-document-id={document_id.clone()}
|
||||
data-workspace-id={workspace_id.clone()}
|
||||
data-page-wide-layout={page_wide_layout.to_string()}
|
||||
data-page-small-text={page_small_text.to_string()}
|
||||
data-layout-density={page_layout_density.clone()}
|
||||
data-page-font={page_font.clone()}
|
||||
data-page-show-heading-numbers={page_show_heading_numbers.to_string()}
|
||||
<div
|
||||
class="document-workspace"
|
||||
data-testid="mnote-document-workspace"
|
||||
data-has-secondary-pane={secondary_visible.to_string()}
|
||||
>
|
||||
<header class="document-shell-header">
|
||||
<div class="document-page-icon" aria-hidden="true">
|
||||
<span class="material-symbols-outlined material-symbols-filled mnote-material-page-icon" data-icon="home"></span>
|
||||
</div>
|
||||
<h1 class="document-title-heading">
|
||||
<textarea
|
||||
id="mnote-page-title-input"
|
||||
class="document-title-input"
|
||||
aria-label="页面标题"
|
||||
data-page-title-input="true"
|
||||
data-document-id={document_id.clone()}
|
||||
data-workspace-id={workspace_id.clone()}
|
||||
data-title-endpoint="/api/documents/title"
|
||||
rows="1"
|
||||
>{title.clone()}</textarea>
|
||||
</h1>
|
||||
<div class="document-shell-meta" aria-label="页面元信息">
|
||||
<span><span aria-hidden="true">"◌"</span>{workspace_label}</span>
|
||||
<span><span aria-hidden="true">"▣"</span>"已同步"</span>
|
||||
</div>
|
||||
</header>
|
||||
<section data-page-aggregate-snapshot="mnote.page_aggregate.v1"></section>
|
||||
<section
|
||||
data-testid="mnote-page-subtree"
|
||||
data-page-tree-source="page_aggregate.tree.pageSubtree"
|
||||
data-page-subtree-present={has_page_subtree.to_string()}
|
||||
></section>
|
||||
<section id="mnote-editor-island" data-editor-host="leptos_tiptap_island">
|
||||
<div
|
||||
id="mnote-leptos-tiptap-island-editor-root"
|
||||
data-testid="mnote-leptos-tiptap-island-editor-root"
|
||||
data-editor-host-kind="leptos_tiptap_island"
|
||||
data-runtime-editor-status="booting"
|
||||
></div>
|
||||
<div
|
||||
class="sr-only"
|
||||
data-editor-host-observability="rust-web-inline-island"
|
||||
data-editor-host-active="leptos_tiptap_island"
|
||||
data-editor-host-requested="leptos_tiptap_island"
|
||||
data-editor-host-status="booting"
|
||||
></div>
|
||||
</section>
|
||||
</main>
|
||||
<DocumentPane model={primary_model} />
|
||||
<div
|
||||
class="document-pane-resizer"
|
||||
data-testid="mnote-secondary-pane-resizer"
|
||||
data-document-pane-resizer="true"
|
||||
aria-hidden="true"
|
||||
hidden={!secondary_visible}
|
||||
></div>
|
||||
<DocumentPane model={secondary_model} />
|
||||
</div>
|
||||
<div class="sr-only" data-mnote-workspace-label>{workspace_label}</div>
|
||||
</PageLayout>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
|
||||
function copyWorkspaceSourceParams(targetUrl) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
['sourceKind', 'rootUri'].forEach(function(name) {
|
||||
['sourceKind', 'rootUri', 'secondaryDocumentId', 'secondarySourceKind', 'secondaryRootUri'].forEach(function(name) {
|
||||
var value = (params.get(name) || '').trim();
|
||||
if (value) targetUrl.searchParams.set(name, value);
|
||||
});
|
||||
|
||||
@@ -1828,12 +1828,68 @@ body {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.document-workspace {
|
||||
--mnote-secondary-pane-width: minmax(320px, 42%);
|
||||
--mnote-secondary-pane-resizer-width: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] {
|
||||
grid-template-columns: minmax(0, 1fr) var(--mnote-secondary-pane-resizer-width) var(--mnote-secondary-pane-width);
|
||||
}
|
||||
|
||||
.document-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-pane[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.document-pane-resizer {
|
||||
display: none;
|
||||
width: var(--mnote-secondary-pane-resizer-width);
|
||||
min-height: calc(100vh - 40px);
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.document-pane-resizer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 64px;
|
||||
bottom: 48px;
|
||||
width: 2px;
|
||||
border-radius: 999px;
|
||||
background: rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.document-shell-header {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
margin: 0 0 50px;
|
||||
}
|
||||
|
||||
.document-pane-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.document-pane-header-row .document-title-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.document-page-icon {
|
||||
display: none;
|
||||
}
|
||||
@@ -1886,6 +1942,26 @@ body {
|
||||
text-underline-offset: 6px;
|
||||
}
|
||||
|
||||
.document-pane-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: #6D6A65;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.document-pane-close:hover {
|
||||
background: #F4F3F3;
|
||||
color: #1B1C1C;
|
||||
}
|
||||
|
||||
.document-shell-meta {
|
||||
display: none;
|
||||
}
|
||||
@@ -2048,6 +2124,16 @@ body {
|
||||
width: min(100%, 980px);
|
||||
}
|
||||
|
||||
.document-pane[data-pane-role="secondary"] .document-shell {
|
||||
width: min(100%, 820px);
|
||||
padding-left: 28px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.document-pane[data-pane-role="secondary"] .document-shell-header {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror,
|
||||
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror p,
|
||||
.document-shell[data-page-small-text="true"] #mnote-leptos-tiptap-island-editor-root .ProseMirror li {
|
||||
@@ -2601,6 +2687,20 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.document-workspace[data-has-secondary-pane="true"] .document-pane-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-pane[data-pane-role="secondary"] .document-shell {
|
||||
padding-top: 32px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.document-shell {
|
||||
width: 100%;
|
||||
padding: 48px 28px 120px;
|
||||
@@ -2711,7 +2811,7 @@ mod tests {
|
||||
fn mnote_css_is_reasonably_sized() {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 菜单与本地 SVG mask 图标会增加体积,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 46000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单和双 pane 布局,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 70000);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-14
@@ -46,29 +46,28 @@ export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly mount: (a: any, b: any) => [number, number, number];
|
||||
readonly unmount: (a: number) => [number, number];
|
||||
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
||||
readonly intounderlyingsink_close: (a: number) => any;
|
||||
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingbytesource_type: (a: number) => number;
|
||||
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
||||
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
||||
readonly intounderlyingsink_close: (a: number) => any;
|
||||
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
||||
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __externref_table_alloc: () => number;
|
||||
|
||||
+49
-68
@@ -785,7 +785,7 @@ function __wbg_get_imports() {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(a, state0.b, arg0, arg1);
|
||||
return wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(a, state0.b, arg0, arg1);
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
@@ -1014,24 +1014,6 @@ function __wbg_get_imports() {
|
||||
const ret = arg0.right;
|
||||
return ret;
|
||||
},
|
||||
__wbg_run_0b0a622deae25fda: function(arg0, arg1, arg2) {
|
||||
try {
|
||||
var state0 = {a: arg1, b: arg2};
|
||||
var cb0 = () => {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(a, state0.b, );
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
};
|
||||
const ret = arg0.run(cb0);
|
||||
return ret;
|
||||
} finally {
|
||||
state0.a = 0;
|
||||
}
|
||||
},
|
||||
__wbg_scrollHeight_5fe8cbb97ae906d8: function(arg0) {
|
||||
const ret = arg0.scrollHeight;
|
||||
return ret;
|
||||
@@ -1039,10 +1021,21 @@ function __wbg_get_imports() {
|
||||
__wbg_scrollIntoView_7725227126cff177: function(arg0, arg1) {
|
||||
arg0.scrollIntoView(arg1 !== 0);
|
||||
},
|
||||
__wbg_scrollTo_f357e55cd25f406f: function(arg0, arg1, arg2) {
|
||||
arg0.scrollTo(arg1, arg2);
|
||||
},
|
||||
__wbg_scrollTop_f548101d48000fe9: function(arg0) {
|
||||
const ret = arg0.scrollTop;
|
||||
return ret;
|
||||
},
|
||||
__wbg_scrollX_c821c038bb4594f3: function() { return handleError(function (arg0) {
|
||||
const ret = arg0.scrollX;
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_scrollY_e80bdf3571bdf5f3: function() { return handleError(function (arg0) {
|
||||
const ret = arg0.scrollY;
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_search_ceee70e1153af3ec: function() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg1.search;
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
@@ -1082,6 +1075,9 @@ function __wbg_get_imports() {
|
||||
__wbg_set_body_be11680f34217f75: function(arg0, arg1) {
|
||||
arg0.body = arg1;
|
||||
},
|
||||
__wbg_set_bubbles_50e942fa177ba6bd: function(arg0, arg1) {
|
||||
arg0.bubbles = arg1 !== 0;
|
||||
},
|
||||
__wbg_set_detail_68bec5c91196ba57: function(arg0, arg1) {
|
||||
arg0.detail = arg1;
|
||||
},
|
||||
@@ -1115,10 +1111,6 @@ function __wbg_get_imports() {
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg_static_accessor_CREATE_TASK_f3ab6a6954bda493: function() {
|
||||
const ret = typeof console === 'undefined' ? null : console?.createTask;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_static_accessor_GLOBAL_8cfadc87a297ca02: function() {
|
||||
const ret = typeof global === 'undefined' ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
@@ -1196,12 +1188,6 @@ function __wbg_get_imports() {
|
||||
const ret = arg0.view;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
},
|
||||
__wbg_warn_3cc416af27dbdc02: function(arg0) {
|
||||
console.warn(arg0);
|
||||
},
|
||||
__wbg_warn_bd0f407277b102f4: function(arg0, arg1, arg2) {
|
||||
console.warn(arg0, arg1, arg2);
|
||||
},
|
||||
__wbg_width_9673a519d7bd5a6a: function(arg0) {
|
||||
const ret = arg0.width;
|
||||
return ret;
|
||||
@@ -1219,43 +1205,43 @@ function __wbg_get_imports() {
|
||||
}
|
||||
}, arguments); },
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1476, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1025, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1715, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 794, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1806, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1632, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 916, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1717, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h837fba73fce77300);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 973, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1631, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 918, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1653, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 940, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1716, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 976, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000009: function(arg0) {
|
||||
@@ -1299,48 +1285,43 @@ function __wbg_get_imports() {
|
||||
};
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441(arg0, arg1);
|
||||
return ret !== 0;
|
||||
function wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h837fba73fce77300(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8(arg0, arg1, arg2, arg3);
|
||||
function wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
|
||||
|
||||
BIN
Binary file not shown.
Vendored
+13
-14
@@ -3,29 +3,28 @@
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const mount: (a: any, b: any) => [number, number, number];
|
||||
export const unmount: (a: number) => [number, number];
|
||||
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||
export const intounderlyingsink_write: (a: number, b: any) => any;
|
||||
export const intounderlyingsink_close: (a: number) => any;
|
||||
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
export const __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
export const intounderlyingbytesource_type: (a: number) => number;
|
||||
export const intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||
export const intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||
export const intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||
export const intounderlyingbytesource_cancel: (a: number) => void;
|
||||
export const __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||
export const intounderlyingsink_write: (a: number, b: any) => any;
|
||||
export const intounderlyingsink_close: (a: number) => any;
|
||||
export const intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
export const __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||
export const intounderlyingsource_pull: (a: number, b: any) => any;
|
||||
export const intounderlyingsource_cancel: (a: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h36e788bd1cd1f0f7: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__hb0ba6117a7ec12e8: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hdfdf165eabd6279b: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2593b18d2d6f8a75: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h03deb55668e1377f: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h837fba73fce77300: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h60e57afd955e8441: (a: number, b: number) => number;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h96c1a94f3b510398: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hd17f30facfdd737f: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hf726e22cfd1ee0a9: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h3a3182d847094e12: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0accad0f5d87788a: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5_4: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h794babeffd4f821f: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2b648e7ac8ec1fb5: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c: (a: number, b: number) => void;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __externref_table_alloc: () => number;
|
||||
|
||||
@@ -19,7 +19,7 @@ use web_sys::{
|
||||
MouseEvent, Node, RequestInit, RequestMode, Response, Storage, WheelEvent,
|
||||
};
|
||||
|
||||
const EDITOR_STAGE_SELECTOR: &str = "#editor-stage";
|
||||
const EDITOR_STAGE_SELECTOR: &str = "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]";
|
||||
const EDITOR_ROOT_SELECTOR: &str = ".editor-surface .ProseMirror";
|
||||
const HANDLE_SHELL_SELECTOR: &str = ".block-handle-shell";
|
||||
const SPIKE_STORAGE_KEY: &str = "mnote.leptos-tiptap-spike.document";
|
||||
@@ -2497,6 +2497,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_editor_instance_id_uses_mount_id_when_available() {
|
||||
assert_eq!(
|
||||
runtime_editor_instance_id(Some(7)),
|
||||
"mnote-leptos-tiptap-spike-7"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_editor_instance_id_falls_back_for_standalone_mode() {
|
||||
assert_eq!(
|
||||
runtime_editor_instance_id(None),
|
||||
"mnote-leptos-tiptap-spike-standalone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_document_storage_key_isolated_per_document_identity() {
|
||||
let doc_a =
|
||||
@@ -2625,6 +2641,13 @@ fn runtime_mount_options() -> Option<RuntimeMountOptions> {
|
||||
RUNTIME_MOUNT_OPTIONS.with(|cell| cell.borrow().clone())
|
||||
}
|
||||
|
||||
fn runtime_editor_instance_id(mount_id: Option<u32>) -> String {
|
||||
mount_id
|
||||
.filter(|value| *value > 0)
|
||||
.map(|value| format!("mnote-leptos-tiptap-spike-{value}"))
|
||||
.unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string())
|
||||
}
|
||||
|
||||
fn runtime_event_target() -> Option<EventTarget> {
|
||||
if let Some((_, target, _)) = runtime_mount_context() {
|
||||
return Some(target);
|
||||
@@ -2762,6 +2785,40 @@ fn schedule_scroll_mnote_block_anchor_from_hash_retry(remaining: u8) {
|
||||
callback.forget();
|
||||
}
|
||||
|
||||
fn current_viewport_scroll() -> Option<(f64, f64)> {
|
||||
let win = window()?;
|
||||
let x = win.scroll_x().ok()?;
|
||||
let y = win.scroll_y().ok()?;
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
fn restore_viewport_scroll(x: f64, y: f64) {
|
||||
if let Some(win) = window() {
|
||||
win.scroll_to_with_x_and_y(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_restore_viewport_scroll(x: f64, y: f64, remaining: u8) {
|
||||
restore_viewport_scroll(x, y);
|
||||
let Some(win) = window() else {
|
||||
return;
|
||||
};
|
||||
if remaining == 0 {
|
||||
return;
|
||||
}
|
||||
let callback = Closure::<dyn FnMut()>::new(move || {
|
||||
restore_viewport_scroll(x, y);
|
||||
if remaining > 1 {
|
||||
schedule_restore_viewport_scroll(x, y, remaining - 1);
|
||||
}
|
||||
});
|
||||
let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
callback.as_ref().unchecked_ref(),
|
||||
80,
|
||||
);
|
||||
callback.forget();
|
||||
}
|
||||
|
||||
fn dispatch_runtime_event<T>(event_name: &'static str, payload: &T)
|
||||
where
|
||||
T: Serialize,
|
||||
@@ -2817,22 +2874,42 @@ fn dispatch_ready_event(payload: &ReadyPayload) {
|
||||
dispatch_runtime_event(READY_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_ready_event_to_target(target: &EventTarget, payload: &ReadyPayload) {
|
||||
dispatch_custom_event_to_target(target, READY_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_change_event(payload: &ChangePayload) {
|
||||
dispatch_runtime_event(CHANGE_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_change_event_to_target(target: &EventTarget, payload: &ChangePayload) {
|
||||
dispatch_custom_event_to_target(target, CHANGE_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_state_event(payload: &StatePayload) {
|
||||
dispatch_runtime_event(STATE_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_state_event_to_target(target: &EventTarget, payload: &StatePayload) {
|
||||
dispatch_custom_event_to_target(target, STATE_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_status_event(payload: &HostStatusPayload) {
|
||||
dispatch_runtime_event(STATUS_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_status_event_to_target(target: &EventTarget, payload: &HostStatusPayload) {
|
||||
dispatch_custom_event_to_target(target, STATUS_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_selection_event(payload: &SelectionPayload) {
|
||||
dispatch_runtime_event(SELECTION_EVENT, payload);
|
||||
}
|
||||
|
||||
fn dispatch_selection_event_to_target(target: &EventTarget, payload: &SelectionPayload) {
|
||||
dispatch_custom_event_to_target(target, SELECTION_EVENT, payload);
|
||||
}
|
||||
|
||||
fn register_unmount_handle<M: Any + leptos::prelude::Mountable + 'static>(
|
||||
id: u32,
|
||||
target: EventTarget,
|
||||
@@ -4960,6 +5037,52 @@ fn dispatch_runtime_state(
|
||||
));
|
||||
}
|
||||
|
||||
fn dispatch_runtime_state_to_target(
|
||||
target: &EventTarget,
|
||||
document_id: Option<String>,
|
||||
workspace_id: Option<String>,
|
||||
title: String,
|
||||
dirty_count: u32,
|
||||
hovered_block: Option<HoveredBlockState>,
|
||||
editor_focused: bool,
|
||||
slash_open: bool,
|
||||
turn_into_open: bool,
|
||||
color_menu_open: bool,
|
||||
more_menu_open: bool,
|
||||
read_only: bool,
|
||||
) {
|
||||
let state = state_payload(
|
||||
document_id.clone(),
|
||||
workspace_id.clone(),
|
||||
title.clone(),
|
||||
dirty_count,
|
||||
hovered_block.clone(),
|
||||
editor_focused,
|
||||
slash_open,
|
||||
turn_into_open,
|
||||
color_menu_open,
|
||||
more_menu_open,
|
||||
read_only,
|
||||
);
|
||||
dispatch_state_event_to_target(target, &state);
|
||||
dispatch_status_event_to_target(
|
||||
target,
|
||||
&status_payload(
|
||||
document_id,
|
||||
workspace_id,
|
||||
title,
|
||||
dirty_count,
|
||||
hovered_block,
|
||||
editor_focused,
|
||||
slash_open,
|
||||
turn_into_open,
|
||||
color_menu_open,
|
||||
more_menu_open,
|
||||
read_only,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn send_selection_state(
|
||||
selection: &TiptapSelectionState,
|
||||
editor_focused: bool,
|
||||
@@ -4972,6 +5095,18 @@ fn send_selection_state(
|
||||
));
|
||||
}
|
||||
|
||||
fn send_selection_state_to_target(
|
||||
target: &EventTarget,
|
||||
selection: &TiptapSelectionState,
|
||||
editor_focused: bool,
|
||||
current_block_index: Option<usize>,
|
||||
) {
|
||||
dispatch_selection_event_to_target(
|
||||
target,
|
||||
&selection_payload(selection, editor_focused, current_block_index),
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_host_document_payload(
|
||||
editor: TiptapEditorHandle,
|
||||
payload: HostDocumentPayload,
|
||||
@@ -5004,6 +5139,7 @@ fn apply_host_document_payload(
|
||||
set_conflict_detection_key.set(next_conflict_detection_key.clone());
|
||||
|
||||
if let Some(content) = payload.content {
|
||||
let viewport_scroll = current_viewport_scroll();
|
||||
let next_content = TiptapContent::json(content);
|
||||
match editor.set_content(next_content) {
|
||||
Ok(()) => {
|
||||
@@ -5015,6 +5151,9 @@ fn apply_host_document_payload(
|
||||
);
|
||||
set_dirty_count.set(0);
|
||||
set_command_feedback.set("宿主文档已同步到编辑器".to_string());
|
||||
if let Some((x, y)) = viewport_scroll {
|
||||
schedule_restore_viewport_scroll(x, y, 10);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
set_command_feedback.set(format!("宿主文档同步失败:{err}"));
|
||||
@@ -6303,6 +6442,16 @@ fn normalize_layout_density(value: Option<String>) -> String {
|
||||
#[component]
|
||||
fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
let editor = TiptapEditorHandle::new();
|
||||
let current_mount_id = runtime_mount_context().map(|(id, _, _)| id);
|
||||
let editor_instance_id = runtime_editor_instance_id(current_mount_id);
|
||||
let editor_stage_id = format!("{editor_instance_id}-stage");
|
||||
let runtime_event_target = runtime_mount_context()
|
||||
.map(|(_, target, _)| target)
|
||||
.or_else(|| {
|
||||
window()
|
||||
.and_then(|win| win.document())
|
||||
.map(|document| document.into())
|
||||
});
|
||||
let initial_document_id = mount_options
|
||||
.document_id
|
||||
.clone()
|
||||
@@ -6420,6 +6569,11 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
.as_ref()
|
||||
.and_then(|opts| opts.embed_default_block_id.clone()),
|
||||
);
|
||||
let command_event_target = runtime_event_target.clone();
|
||||
let ready_event_target = runtime_event_target.clone();
|
||||
let change_event_target = runtime_event_target.clone();
|
||||
let selection_event_target = runtime_event_target.clone();
|
||||
let slash_change_event_target = runtime_event_target.clone();
|
||||
|
||||
{
|
||||
let block_menu_open = block_menu_open;
|
||||
@@ -6483,6 +6637,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
let set_show_heading_numbers = set_show_heading_numbers;
|
||||
let set_embed_default_block_id = set_embed_default_block_id;
|
||||
move |_| {
|
||||
let command_event_target = command_event_target.clone();
|
||||
let command_listener =
|
||||
Closure::<dyn FnMut(Event)>::wrap(Box::new(move |event: Event| {
|
||||
let Some(custom_event) = event.dyn_ref::<CustomEvent>() else {
|
||||
@@ -6564,19 +6719,22 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
let editable = payload.editable.unwrap_or(true);
|
||||
set_editor_editable.set(editable);
|
||||
set_read_only.set(!editable);
|
||||
dispatch_runtime_state(
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
if let Some(target) = command_event_target.as_ref() {
|
||||
dispatch_runtime_state_to_target(
|
||||
target,
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
}
|
||||
}
|
||||
HostCommandKind::SetPageOptions => {
|
||||
if let Some(page_options) = payload.page_options {
|
||||
@@ -6602,22 +6760,27 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
let current_block = current_block_info_from_index(
|
||||
hovered_block.get_untracked().map(|block| block.index),
|
||||
);
|
||||
dispatch_status_event(&HostStatusPayload {
|
||||
document_id: document_id.get_untracked(),
|
||||
workspace_id: workspace_id.get_untracked(),
|
||||
title: title.get_untracked(),
|
||||
dirty_count: dirty_count.get_untracked(),
|
||||
selected_block_index: current_block.index,
|
||||
current_block_id: current_block.block_id,
|
||||
editor_focused: editor_focused.get_untracked(),
|
||||
read_only: read_only.get_untracked(),
|
||||
slash_open: slash_open.get_untracked(),
|
||||
toolbar_open: toolbar_overlay_locked(
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
),
|
||||
});
|
||||
if let Some(target) = command_event_target.as_ref() {
|
||||
dispatch_status_event_to_target(
|
||||
target,
|
||||
&HostStatusPayload {
|
||||
document_id: document_id.get_untracked(),
|
||||
workspace_id: workspace_id.get_untracked(),
|
||||
title: title.get_untracked(),
|
||||
dirty_count: dirty_count.get_untracked(),
|
||||
selected_block_index: current_block.index,
|
||||
current_block_id: current_block.block_id,
|
||||
editor_focused: editor_focused.get_untracked(),
|
||||
read_only: read_only.get_untracked(),
|
||||
slash_open: slash_open.get_untracked(),
|
||||
toolbar_open: toolbar_overlay_locked(
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
HostCommandKind::InsertInlineReference
|
||||
| HostCommandKind::InsertEmbedReference => {
|
||||
@@ -7379,7 +7542,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
}}
|
||||
|
||||
<div
|
||||
id="editor-stage"
|
||||
id=editor_stage_id.clone()
|
||||
class="editor-stage"
|
||||
data-testid="mnote-leptos-tiptap-editor-stage"
|
||||
data-page-wide-layout=move || wide_layout.get().to_string()
|
||||
@@ -9169,6 +9332,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
let show_category = index == 0
|
||||
|| SLASH_ACTIONS[index - 1].category != category;
|
||||
let testid = format!("slash-item-{}", action.id);
|
||||
let slash_change_event_target = slash_change_event_target.clone();
|
||||
view! {
|
||||
<>
|
||||
{if show_category {
|
||||
@@ -9189,39 +9353,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
set_html_output.set(html.clone());
|
||||
set_document_json.set(snapshot.clone());
|
||||
set_json_output.set(json_text);
|
||||
dispatch_change_event(&ChangePayload {
|
||||
document_id: document_id.get_untracked(),
|
||||
workspace_id: workspace_id.get_untracked(),
|
||||
title: title.get_untracked(),
|
||||
content: snapshot.clone(),
|
||||
meta: ChangeMetaPayload {
|
||||
dirty_count: dirty_count.get_untracked(),
|
||||
editor_focused: editor_focused.get_untracked(),
|
||||
slash_open: slash_open.get_untracked(),
|
||||
toolbar_open: toolbar_overlay_locked(
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
),
|
||||
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
|
||||
revision: revision.get_untracked(),
|
||||
conflict_detection_key: conflict_detection_key.get_untracked(),
|
||||
read_only: read_only.get_untracked(),
|
||||
},
|
||||
});
|
||||
dispatch_runtime_state(
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
if let Some(target) = slash_change_event_target.as_ref() {
|
||||
dispatch_change_event_to_target(
|
||||
target,
|
||||
&ChangePayload {
|
||||
document_id: document_id.get_untracked(),
|
||||
workspace_id: workspace_id.get_untracked(),
|
||||
title: title.get_untracked(),
|
||||
content: snapshot.clone(),
|
||||
meta: ChangeMetaPayload {
|
||||
dirty_count: dirty_count.get_untracked(),
|
||||
editor_focused: editor_focused.get_untracked(),
|
||||
slash_open: slash_open.get_untracked(),
|
||||
toolbar_open: toolbar_overlay_locked(
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
),
|
||||
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
|
||||
revision: revision.get_untracked(),
|
||||
conflict_detection_key: conflict_detection_key.get_untracked(),
|
||||
read_only: read_only.get_untracked(),
|
||||
},
|
||||
},
|
||||
);
|
||||
dispatch_runtime_state_to_target(
|
||||
target,
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
}
|
||||
match persist_document_state(
|
||||
&runtime_persisted_identity(document_id, workspace_id),
|
||||
&title.get_untracked(),
|
||||
@@ -9261,7 +9431,7 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
}}
|
||||
|
||||
<TiptapEditor
|
||||
id="mnote-leptos-tiptap-spike"
|
||||
id=editor_instance_id.clone()
|
||||
editor=editor
|
||||
initial_content=initial_editor_content.clone()
|
||||
placeholder="输入 “/” 打开命令菜单;试试 heading / list / todo / quote / code block / divider"
|
||||
@@ -9290,39 +9460,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
let snapshot =
|
||||
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
|
||||
schedule_scroll_mnote_block_anchor_from_hash();
|
||||
dispatch_ready_event(&ReadyPayload {
|
||||
runtime_name: RUNTIME_NAME,
|
||||
selectors: BridgeSelectorsPayload {
|
||||
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
|
||||
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
|
||||
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
|
||||
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
|
||||
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
|
||||
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
|
||||
},
|
||||
supported_commands: vec![
|
||||
"replaceContent",
|
||||
"setEditable",
|
||||
"undo",
|
||||
"redo",
|
||||
"focus",
|
||||
"requestCurrentBlockId",
|
||||
],
|
||||
supports_embedded_mode: true,
|
||||
});
|
||||
dispatch_runtime_state(
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
if let Some(target) = ready_event_target.as_ref() {
|
||||
dispatch_ready_event_to_target(
|
||||
target,
|
||||
&ReadyPayload {
|
||||
runtime_name: RUNTIME_NAME,
|
||||
selectors: BridgeSelectorsPayload {
|
||||
root: "[data-testid=\"mnote-leptos-tiptap-host\"]",
|
||||
stage: "[data-testid=\"mnote-leptos-tiptap-editor-stage\"]",
|
||||
editor: "[data-testid=\"mnote-leptos-tiptap-editor-root\"]",
|
||||
toolbar: "[data-testid=\"mnote-leptos-tiptap-toolbar\"]",
|
||||
slash_menu: "[data-testid=\"mnote-leptos-tiptap-slash-menu\"]",
|
||||
handle: "[data-testid=\"mnote-leptos-tiptap-handle\"]",
|
||||
},
|
||||
supported_commands: vec![
|
||||
"replaceContent",
|
||||
"setEditable",
|
||||
"undo",
|
||||
"redo",
|
||||
"focus",
|
||||
"requestCurrentBlockId",
|
||||
],
|
||||
supports_embedded_mode: true,
|
||||
},
|
||||
);
|
||||
dispatch_runtime_state_to_target(
|
||||
target,
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
}
|
||||
match persist_document_state(
|
||||
&runtime_persisted_identity(document_id, workspace_id),
|
||||
&title.get_untracked(),
|
||||
@@ -9345,39 +9521,45 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
set_dirty_count.update(|count| *count += 1);
|
||||
let snapshot =
|
||||
sync_editor_outputs(editor, set_html_output, set_document_json, set_json_output);
|
||||
dispatch_change_event(&ChangePayload {
|
||||
document_id: document_id.get_untracked(),
|
||||
workspace_id: workspace_id.get_untracked(),
|
||||
title: title.get_untracked(),
|
||||
content: snapshot.clone(),
|
||||
meta: ChangeMetaPayload {
|
||||
dirty_count: dirty_count.get_untracked(),
|
||||
editor_focused: editor_focused.get_untracked(),
|
||||
slash_open: slash_open.get_untracked(),
|
||||
toolbar_open: toolbar_overlay_locked(
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
),
|
||||
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
|
||||
revision: revision.get_untracked(),
|
||||
conflict_detection_key: conflict_detection_key.get_untracked(),
|
||||
read_only: read_only.get_untracked(),
|
||||
},
|
||||
});
|
||||
dispatch_runtime_state(
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
if let Some(target) = change_event_target.as_ref() {
|
||||
dispatch_change_event_to_target(
|
||||
target,
|
||||
&ChangePayload {
|
||||
document_id: document_id.get_untracked(),
|
||||
workspace_id: workspace_id.get_untracked(),
|
||||
title: title.get_untracked(),
|
||||
content: snapshot.clone(),
|
||||
meta: ChangeMetaPayload {
|
||||
dirty_count: dirty_count.get_untracked(),
|
||||
editor_focused: editor_focused.get_untracked(),
|
||||
slash_open: slash_open.get_untracked(),
|
||||
toolbar_open: toolbar_overlay_locked(
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
),
|
||||
selected_block_index: hovered_block.get_untracked().map(|block| block.index),
|
||||
revision: revision.get_untracked(),
|
||||
conflict_detection_key: conflict_detection_key.get_untracked(),
|
||||
read_only: read_only.get_untracked(),
|
||||
},
|
||||
},
|
||||
);
|
||||
dispatch_runtime_state_to_target(
|
||||
target,
|
||||
document_id.get_untracked(),
|
||||
workspace_id.get_untracked(),
|
||||
title.get_untracked(),
|
||||
dirty_count.get_untracked(),
|
||||
hovered_block.get_untracked(),
|
||||
editor_focused.get_untracked(),
|
||||
slash_open.get_untracked(),
|
||||
turn_into_open.get_untracked(),
|
||||
color_menu_open.get_untracked(),
|
||||
more_menu_open.get_untracked(),
|
||||
read_only.get_untracked(),
|
||||
);
|
||||
}
|
||||
match persist_document_state(
|
||||
&runtime_persisted_identity(document_id, workspace_id),
|
||||
&title.get_untracked(),
|
||||
@@ -9422,11 +9604,14 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
set_block_menu_anchor.set(None);
|
||||
set_hovered_block.set(None);
|
||||
}
|
||||
send_selection_state(
|
||||
&selection_clone,
|
||||
editor_focused.get_untracked(),
|
||||
hovered_block.get_untracked().map(|block| block.index),
|
||||
);
|
||||
if let Some(target) = selection_event_target.as_ref() {
|
||||
send_selection_state_to_target(
|
||||
target,
|
||||
&selection_clone,
|
||||
editor_focused.get_untracked(),
|
||||
hovered_block.get_untracked().map(|block| block.index),
|
||||
);
|
||||
}
|
||||
}
|
||||
attr:class="editor-surface"
|
||||
attr:data-testid="mnote-leptos-tiptap-editor-root"
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { chromium } = require("playwright");
|
||||
const { findFreePort, waitForGateway } = require("./task114-rust-web-gateway-entry-smoke.js");
|
||||
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
|
||||
const TYPE_DELAY_MS = Number(process.env.MNOTE_SMOKE_TYPE_DELAY_MS || 20);
|
||||
|
||||
function buildFixtureEnv(port) {
|
||||
return {
|
||||
...process.env,
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
|
||||
MNOTE_WEB_BIND: `127.0.0.1:${port}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
|
||||
MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
|
||||
"documents:getMeta": {
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_demo",
|
||||
title: "双栏验收页面",
|
||||
updated_at: "2026-05-08T10:00:00Z",
|
||||
can_edit: true,
|
||||
word_count: 10,
|
||||
character_count: 32,
|
||||
block_count: 1,
|
||||
},
|
||||
"documents:getContent": {
|
||||
title: "双栏验收页面",
|
||||
content: [
|
||||
{
|
||||
id: "block_1",
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "fixture init" }],
|
||||
},
|
||||
],
|
||||
revision: 7,
|
||||
conflict_detection_key: "doc_1:7",
|
||||
pageSubtree: { rootNodeId: "doc_1", outline: [] },
|
||||
},
|
||||
"sidebar:datasetList": {
|
||||
active_workspace_id: "ws_demo",
|
||||
workspaces: [{ id: "ws_demo", name: "双栏空间" }],
|
||||
documents: [
|
||||
{
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_demo",
|
||||
title: "双栏验收页面",
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-05-08T10:00:00Z",
|
||||
updated_at: "2026-05-08T10:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "doc_other",
|
||||
workspace_id: "ws_demo",
|
||||
title: "Fixture Other",
|
||||
parent_id: null,
|
||||
sort_order: 1,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "2026-05-08T10:00:00Z",
|
||||
updated_at: "2026-05-08T10:00:00Z",
|
||||
},
|
||||
],
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
mindmap_assets: [],
|
||||
trashed_mindmap_assets: [],
|
||||
table_assets: [],
|
||||
trashed_table_assets: [],
|
||||
mindmap_docs: [],
|
||||
mindmap_asset_children: {},
|
||||
},
|
||||
"bridgeLogs:listWorkspaceOverview": {
|
||||
workspace_id: "ws_demo",
|
||||
command_logs: [],
|
||||
domain_events: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
filters: {
|
||||
command_status: null,
|
||||
event_status: null,
|
||||
target_page_id: null,
|
||||
target_block_id: null,
|
||||
aggregate_type: null,
|
||||
aggregate_id: null,
|
||||
},
|
||||
generated_at: "2026-05-08T10:00:00Z",
|
||||
},
|
||||
}),
|
||||
MNOTE_WEB_MUTATION_FIXTURES_JSON: JSON.stringify({
|
||||
"documents:updateContent": {
|
||||
ok: true,
|
||||
updated_at: "2026-05-08T10:00:10Z",
|
||||
revision: 8,
|
||||
conflict_detection_key: "doc_1:8",
|
||||
},
|
||||
"documents:updateTitle": {
|
||||
ok: true,
|
||||
title: "双栏验收页面",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function startGateway(port) {
|
||||
return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
|
||||
cwd: "/mnt/Data1T/mnote/rust",
|
||||
env: buildFixtureEnv(port),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function createLocalFolderFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-dual-pane-"));
|
||||
const readmePath = path.join(root, "README.md");
|
||||
const sidePath = path.join(root, "side.md");
|
||||
const thirdPath = path.join(root, "third.md");
|
||||
const readmeParagraphs = Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`);
|
||||
const seed = [
|
||||
"---",
|
||||
"title: Local Dual Pane",
|
||||
"---",
|
||||
"",
|
||||
...readmeParagraphs,
|
||||
"",
|
||||
].join("\n");
|
||||
fs.writeFileSync(readmePath, seed, "utf8");
|
||||
fs.writeFileSync(
|
||||
sidePath,
|
||||
[
|
||||
"---",
|
||||
"title: Local Side",
|
||||
"---",
|
||||
"",
|
||||
"side init",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
thirdPath,
|
||||
[
|
||||
"---",
|
||||
"title: Local Third",
|
||||
"---",
|
||||
"",
|
||||
"third init",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
root,
|
||||
rootUri: `file://${root}`,
|
||||
readmePath,
|
||||
sidePath,
|
||||
thirdPath,
|
||||
documentId: "local-md:README.md",
|
||||
sideDocumentId: "local-md:side.md",
|
||||
thirdDocumentId: "local-md:third.md",
|
||||
};
|
||||
}
|
||||
|
||||
function paneEditorSelector(role) {
|
||||
return `.document-pane[data-pane-role="${role}"] .editor-surface .ProseMirror[contenteditable="true"]`;
|
||||
}
|
||||
|
||||
function paneRootSelector(role) {
|
||||
return `.document-pane[data-pane-role="${role}"] [data-testid="mnote-leptos-tiptap-island-editor-root"]`;
|
||||
}
|
||||
|
||||
function paneSelector(role) {
|
||||
return `.document-pane[data-pane-role="${role}"]`;
|
||||
}
|
||||
|
||||
async function waitForDualPaneReady(page) {
|
||||
await page.waitForFunction(
|
||||
({ primarySelector, secondarySelector, primaryRootSelector, secondaryRootSelector }) => {
|
||||
const primaryEditor = document.querySelector(primarySelector);
|
||||
const secondaryEditor = document.querySelector(secondarySelector);
|
||||
const primaryRoot = document.querySelector(primaryRootSelector);
|
||||
const secondaryRoot = document.querySelector(secondaryRootSelector);
|
||||
const ready = (editor, root) =>
|
||||
editor instanceof HTMLElement &&
|
||||
editor.isContentEditable &&
|
||||
root instanceof HTMLElement &&
|
||||
root.getAttribute("data-runtime-editor-status") !== "error";
|
||||
return ready(primaryEditor, primaryRoot) && ready(secondaryEditor, secondaryRoot);
|
||||
},
|
||||
{
|
||||
primarySelector: paneEditorSelector("primary"),
|
||||
secondarySelector: paneEditorSelector("secondary"),
|
||||
primaryRootSelector: paneRootSelector("primary"),
|
||||
secondaryRootSelector: paneRootSelector("secondary"),
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSinglePaneReady(page) {
|
||||
await page.waitForFunction(
|
||||
({ primarySelector, primaryRootSelector, secondaryPaneSelector }) => {
|
||||
const primaryEditor = document.querySelector(primarySelector);
|
||||
const primaryRoot = document.querySelector(primaryRootSelector);
|
||||
const secondaryPane = document.querySelector(secondaryPaneSelector);
|
||||
const secondaryVisible =
|
||||
secondaryPane instanceof HTMLElement &&
|
||||
!secondaryPane.hasAttribute("hidden") &&
|
||||
secondaryPane.getAttribute("data-pane-visible") !== "false";
|
||||
return (
|
||||
primaryEditor instanceof HTMLElement &&
|
||||
primaryEditor.isContentEditable &&
|
||||
primaryRoot instanceof HTMLElement &&
|
||||
primaryRoot.getAttribute("data-runtime-editor-status") !== "error" &&
|
||||
!secondaryVisible
|
||||
);
|
||||
},
|
||||
{
|
||||
primarySelector: paneEditorSelector("primary"),
|
||||
primaryRootSelector: paneRootSelector("primary"),
|
||||
secondaryPaneSelector: paneSelector("secondary"),
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function typePaneText(page, role, text) {
|
||||
const editor = page.locator(paneEditorSelector(role)).first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Control+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(text, { delay: TYPE_DELAY_MS });
|
||||
}
|
||||
|
||||
async function appendPaneText(page, role, text) {
|
||||
const editor = page.locator(paneEditorSelector(role)).first();
|
||||
await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("Control+End");
|
||||
await page.keyboard.type(text, { delay: TYPE_DELAY_MS });
|
||||
}
|
||||
|
||||
async function waitForPaneText(page, role, text) {
|
||||
await page.waitForFunction(
|
||||
({ selector, expected }) => {
|
||||
const editor = document.querySelector(selector);
|
||||
return (editor?.textContent || "").includes(expected);
|
||||
},
|
||||
{
|
||||
selector: paneEditorSelector(role),
|
||||
expected: text,
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readPrimaryTitleState(page) {
|
||||
return await page.evaluate(() => {
|
||||
const primaryInput = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]');
|
||||
const primaryMeta = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-current="true"]');
|
||||
const breadcrumb = document.querySelector('.wolai-breadcrumb-current [data-page-title-current="true"]');
|
||||
return {
|
||||
documentTitle: document.title || "",
|
||||
inputValue: primaryInput instanceof HTMLTextAreaElement ? primaryInput.value || "" : "",
|
||||
metaTitle: primaryMeta instanceof HTMLElement ? primaryMeta.textContent || "" : "",
|
||||
breadcrumbTitle: breadcrumb instanceof HTMLElement ? breadcrumb.textContent || "" : "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPaneStatus(page, role, status) {
|
||||
await page.waitForFunction(
|
||||
({ selector, expected }) => {
|
||||
const root = document.querySelector(selector);
|
||||
return root?.getAttribute("data-runtime-editor-status") === expected;
|
||||
},
|
||||
{
|
||||
selector: paneRootSelector(role),
|
||||
expected: status,
|
||||
},
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readPaneText(page, role) {
|
||||
return await page.evaluate((selector) => {
|
||||
const editor = document.querySelector(selector);
|
||||
return editor?.textContent || "";
|
||||
}, paneEditorSelector(role));
|
||||
}
|
||||
|
||||
async function readActivePaneRole(page) {
|
||||
return await page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
return active?.closest?.(".document-pane")?.getAttribute?.("data-pane-role") || null;
|
||||
});
|
||||
}
|
||||
|
||||
async function readPaneScrollState(page, role) {
|
||||
return await page.evaluate(({ paneSelector, editorSelector }) => {
|
||||
const pane = document.querySelector(paneSelector);
|
||||
const editor = document.querySelector(editorSelector);
|
||||
const findScrollable = (start) => {
|
||||
let current = start;
|
||||
while (current instanceof HTMLElement) {
|
||||
if (current.scrollHeight > current.clientHeight + 8) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const target = findScrollable(editor) || findScrollable(pane);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
};
|
||||
}, {
|
||||
paneSelector: paneSelector(role),
|
||||
editorSelector: paneEditorSelector(role),
|
||||
});
|
||||
}
|
||||
|
||||
async function setPaneScrollTop(page, role, top) {
|
||||
return await page.evaluate(({ paneSelector, editorSelector, topValue }) => {
|
||||
const pane = document.querySelector(paneSelector);
|
||||
const editor = document.querySelector(editorSelector);
|
||||
const findScrollable = (start) => {
|
||||
let current = start;
|
||||
while (current instanceof HTMLElement) {
|
||||
if (current.scrollHeight > current.clientHeight + 8) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const target = findScrollable(editor) || findScrollable(pane);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
target.scrollTop = topValue;
|
||||
return {
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
};
|
||||
}, {
|
||||
paneSelector: paneSelector(role),
|
||||
editorSelector: paneEditorSelector(role),
|
||||
topValue: top,
|
||||
});
|
||||
}
|
||||
|
||||
async function readViewportScroll(page) {
|
||||
return await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
|
||||
}
|
||||
|
||||
async function setViewportScroll(page, top) {
|
||||
return await page.evaluate((topValue) => {
|
||||
window.scrollTo({ top: topValue, left: 0, behavior: "auto" });
|
||||
return { x: window.scrollX, y: window.scrollY };
|
||||
}, top);
|
||||
}
|
||||
|
||||
async function waitForRequestCount(requests, startIndex, predicate, expected, label) {
|
||||
const deadline = Date.now() + UI_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const count = requests.slice(startIndex).filter(predicate).length;
|
||||
if (count >= expected) {
|
||||
return count;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`${label} 超时,期望至少 ${expected} 条`);
|
||||
}
|
||||
|
||||
async function readDocumentSessionSnapshot(page) {
|
||||
return await page.evaluate(() => {
|
||||
const debug = window.__mnoteDebugDocumentSessions;
|
||||
if (!debug || typeof debug.snapshot !== "function") {
|
||||
throw new Error("缺少文档 session debug 快照");
|
||||
}
|
||||
return debug.snapshot();
|
||||
});
|
||||
}
|
||||
|
||||
function countRequests(requests, startIndex, predicate) {
|
||||
return requests.slice(startIndex).filter(predicate).length;
|
||||
}
|
||||
|
||||
function isSaveRequest(record) {
|
||||
return record.method === "POST" && record.url.includes("/api/documents/save");
|
||||
}
|
||||
|
||||
function isTreeEventRequest(record) {
|
||||
return record.method === "GET" && record.url.includes("/api/tree/events");
|
||||
}
|
||||
|
||||
function isLocalFolderEventRequest(record) {
|
||||
return record.method === "GET" && record.url.includes("/api/local-folder/events");
|
||||
}
|
||||
|
||||
async function runFixturePhase(page, baseUrl, requests) {
|
||||
const url = `${baseUrl}/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1`;
|
||||
const openIndex = requests.length;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, openIndex, isTreeEventRequest, 1, "tree EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource");
|
||||
|
||||
const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first();
|
||||
await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const primaryToSecondary = `fixture-primary-${Date.now().toString().slice(-6)}`;
|
||||
const primarySaveIndex = requests.length;
|
||||
await typePaneText(page, "primary", primaryToSecondary);
|
||||
await waitForPaneText(page, "primary", primaryToSecondary);
|
||||
await waitForPaneText(page, "secondary", primaryToSecondary);
|
||||
await waitForPaneStatus(page, "primary", "saved");
|
||||
await waitForPaneStatus(page, "secondary", "saved");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, primarySaveIndex, isSaveRequest), 1, "同 session 双 view 主 pane 输入后应只触发一次保存请求");
|
||||
assert.equal(await readActivePaneRole(page), "primary", "primary 输入后焦点不应被同步到 secondary");
|
||||
|
||||
const secondaryToPrimary = `fixture-secondary-${(Date.now() + 1).toString().slice(-6)}`;
|
||||
const secondarySaveIndex = requests.length;
|
||||
await typePaneText(page, "secondary", secondaryToPrimary);
|
||||
await waitForPaneText(page, "primary", secondaryToPrimary);
|
||||
await waitForPaneText(page, "secondary", secondaryToPrimary);
|
||||
await waitForPaneStatus(page, "primary", "saved");
|
||||
await waitForPaneStatus(page, "secondary", "saved");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, secondarySaveIndex, isSaveRequest), 1, "同 session 双 view 次 pane 输入后应只触发一次保存请求");
|
||||
assert.equal(await readActivePaneRole(page), "secondary", "secondary 输入后焦点不应被同步到 primary");
|
||||
|
||||
const reloadIndex = requests.length;
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, reloadIndex, isTreeEventRequest, 1, "reload 后 tree EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, reloadIndex, isTreeEventRequest), 1, "reload 后双 pane 仍应只建立一条 tree EventSource");
|
||||
const fixtureSnapshot = await readDocumentSessionSnapshot(page);
|
||||
assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session");
|
||||
assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view");
|
||||
|
||||
const navigationIndex = requests.length;
|
||||
await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith("/documents/doc_other"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, navigationIndex, isTreeEventRequest, 1, "sidebar 导航后的 tree EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 1, "sidebar 导航后当前页面仍应只建立一条 tree EventSource");
|
||||
const navigatedFixtureUrl = new URL(page.url());
|
||||
assert.equal(
|
||||
navigatedFixtureUrl.searchParams.get("secondaryDocumentId"),
|
||||
"doc_1",
|
||||
"fixture sidebar 导航后 secondaryDocumentId 应保持原值",
|
||||
);
|
||||
}
|
||||
|
||||
async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
|
||||
const differentDocUrl = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.sideDocumentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`;
|
||||
const differentDocIndex = requests.length;
|
||||
await page.goto(differentDocUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, differentDocIndex, isLocalFolderEventRequest, 1, "不同文档 local folder EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, differentDocIndex, isLocalFolderEventRequest), 1, "同 rootUri 不同文档双 pane 也应只复用一条 local-folder EventSource");
|
||||
const differentDocSnapshot = await readDocumentSessionSnapshot(page);
|
||||
assert.equal(differentDocSnapshot.sessionCount, 2, "不同文档双开时应建立两个 session");
|
||||
assert.equal(differentDocSnapshot.localFolderChannelCount, 1, "同 rootUri 不同文档双开时应只复用一个 local-folder channel");
|
||||
assert.deepEqual(
|
||||
differentDocSnapshot.sessions.map((item) => item.documentId).sort(),
|
||||
[fixture.documentId, fixture.sideDocumentId].sort(),
|
||||
"不同文档双开时 session 应分别归属到两个 documentId",
|
||||
);
|
||||
await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((nextUrl) => decodeURIComponent(nextUrl.pathname).endsWith(`/documents/${fixture.thirdDocumentId}`), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await waitForDualPaneReady(page);
|
||||
const navigatedUrl = new URL(page.url());
|
||||
assert.equal(
|
||||
navigatedUrl.searchParams.get("secondaryDocumentId"),
|
||||
fixture.sideDocumentId,
|
||||
"左侧普通导航后 secondaryDocumentId 应保持原值",
|
||||
);
|
||||
assert.equal(
|
||||
navigatedUrl.searchParams.get("secondarySourceKind"),
|
||||
"local_folder",
|
||||
"左侧普通导航后 secondarySourceKind 应保持 local_folder",
|
||||
);
|
||||
assert.equal(
|
||||
navigatedUrl.searchParams.get("secondaryRootUri"),
|
||||
fixture.rootUri,
|
||||
"左侧普通导航后 secondaryRootUri 应保持原值",
|
||||
);
|
||||
const primaryTextAfterNavigation = await readPaneText(page, "primary");
|
||||
const secondaryTextAfterNavigation = await readPaneText(page, "secondary");
|
||||
assert(primaryTextAfterNavigation.includes("third init"), "左侧普通导航后 primary pane 应切到新文档");
|
||||
assert(secondaryTextAfterNavigation.includes("side init"), "左侧普通导航后 secondary pane 应保持原文档");
|
||||
const topbarTitle = await page.locator(".wolai-topbar [data-page-title-current='true']").first().textContent();
|
||||
assert((topbarTitle || "").includes("Local Third"), "左侧普通导航后共享 topbar 标题应切到 primary 文档");
|
||||
const activeThirdRow = page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first();
|
||||
const activeState = await activeThirdRow.getAttribute("data-active");
|
||||
const selectedState = await activeThirdRow.getAttribute("data-selected");
|
||||
assert(
|
||||
activeState === "true" || selectedState === "true",
|
||||
"左侧普通导航后共享侧栏 active/selected 状态应切到新的 primary 文档",
|
||||
);
|
||||
|
||||
const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.documentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`;
|
||||
|
||||
const openIndex = requests.length;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, openIndex, isLocalFolderEventRequest, 1, "local folder EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, openIndex, isLocalFolderEventRequest), 1, "同 rootUri 双 pane 不应建立第二条 local-folder EventSource");
|
||||
assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 0, "local folder 页面不应建立 tree EventSource");
|
||||
|
||||
const reloadIndex = requests.length;
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await waitForRequestCount(requests, reloadIndex, isLocalFolderEventRequest, 1, "reload 后 local folder EventSource 建连");
|
||||
await page.waitForTimeout(800);
|
||||
assert.equal(countRequests(requests, reloadIndex, isLocalFolderEventRequest), 1, "reload 后同 rootUri 双 pane 仍应只建立一条 local-folder EventSource");
|
||||
const sameDocSnapshot = await readDocumentSessionSnapshot(page);
|
||||
assert.equal(sameDocSnapshot.sessionCount, 1, "同文档 local folder 双开时应只复用一个 session");
|
||||
assert.equal(sameDocSnapshot.sessions[0]?.viewCount, 2, "同文档 local folder 双开时单 session 应挂两个 view");
|
||||
|
||||
const viewportBefore = await setViewportScroll(page, 260);
|
||||
assert((viewportBefore?.y || 0) >= 200, "本地双栏页面应可滚动到可观察位置");
|
||||
const externalText = `external-sync-${Date.now().toString().slice(-6)}`;
|
||||
fs.writeFileSync(
|
||||
fixture.readmePath,
|
||||
[
|
||||
"---",
|
||||
"title: Local Dual Pane",
|
||||
"---",
|
||||
"",
|
||||
...Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`),
|
||||
externalText,
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await waitForPaneText(page, "primary", externalText);
|
||||
await waitForPaneText(page, "secondary", externalText);
|
||||
await waitForPaneStatus(page, "primary", "synced-external-change");
|
||||
await waitForPaneStatus(page, "secondary", "synced-external-change");
|
||||
await page.waitForTimeout(800);
|
||||
const viewportAfter = await readViewportScroll(page);
|
||||
assert(
|
||||
Math.abs((viewportAfter?.y || 0) - (viewportBefore?.y || 0)) < 40,
|
||||
"远端 replaceContent 后共享页面滚动不应被重置",
|
||||
);
|
||||
|
||||
const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8");
|
||||
assert(savedMarkdown.includes(externalText), "外部写盘未落到本地 Markdown 文件");
|
||||
|
||||
await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((nextUrl) => !nextUrl.searchParams.has("secondaryDocumentId"), {
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
waitUntil: "commit",
|
||||
});
|
||||
await waitForSinglePaneReady(page);
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForSinglePaneReady(page);
|
||||
const finalUrl = new URL(page.url());
|
||||
assert(!finalUrl.searchParams.has("secondaryDocumentId"), "关闭 secondary 后 reload 不应恢复 secondaryDocumentId");
|
||||
assert(!finalUrl.searchParams.has("secondarySourceKind"), "关闭 secondary 后 reload 不应恢复 secondarySourceKind");
|
||||
assert(!finalUrl.searchParams.has("secondaryRootUri"), "关闭 secondary 后 reload 不应恢复 secondaryRootUri");
|
||||
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForDualPaneReady(page);
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.document-pane[data-pane-role="secondary"]')?.remove();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
const oneViewSnapshot = await readDocumentSessionSnapshot(page);
|
||||
assert.equal(oneViewSnapshot.sessionCount, 1, "移除一个 pane 后 session 不应提前释放");
|
||||
assert.equal(oneViewSnapshot.sessions[0]?.viewCount, 1, "移除一个 pane 后同 session 应只剩一个 view");
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.document-pane[data-pane-role="primary"]')?.remove();
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
const releasedSnapshot = await readDocumentSessionSnapshot(page);
|
||||
assert.equal(releasedSnapshot.sessionCount, 0, "最后一个 view 卸载后应延迟释放 session");
|
||||
assert.equal(releasedSnapshot.localFolderChannelCount, 0, "最后一个 view 卸载后应回收 local-folder channel");
|
||||
}
|
||||
|
||||
async function runSinglePaneTitlePhase(page, baseUrl, fixture) {
|
||||
const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}`;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await waitForSinglePaneReady(page);
|
||||
const expectedTitle = "Local Dual Pane";
|
||||
const beforeClick = await readPrimaryTitleState(page);
|
||||
assert.equal(beforeClick.documentTitle.trim(), expectedTitle, "单页本地文档首屏 document.title 不应被隐藏 secondary 覆盖成“无标题”");
|
||||
assert.equal(beforeClick.inputValue.trim(), expectedTitle, "单页本地文档首屏标题输入框不应被隐藏 secondary 覆盖成“无标题”");
|
||||
assert.equal(beforeClick.metaTitle.trim(), expectedTitle, "单页本地文档首屏页头标题不应被隐藏 secondary 覆盖成“无标题”");
|
||||
assert.equal(beforeClick.breadcrumbTitle.trim(), expectedTitle, "单页本地文档首屏 breadcrumb 标题不应被隐藏 secondary 覆盖成“无标题”");
|
||||
|
||||
const titleInput = page.locator('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]').first();
|
||||
await titleInput.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForTimeout(250);
|
||||
const afterClick = await readPrimaryTitleState(page);
|
||||
assert.equal(afterClick.documentTitle.trim(), expectedTitle, "点击标题后 document.title 不应闪成“无标题”");
|
||||
assert.equal(afterClick.inputValue.trim(), expectedTitle, "点击标题后输入框标题不应闪成“无标题”");
|
||||
assert.equal(afterClick.metaTitle.trim(), expectedTitle, "点击标题后页头标题不应闪成“无标题”");
|
||||
assert.equal(afterClick.breadcrumbTitle.trim(), expectedTitle, "点击标题后 breadcrumb 标题不应闪成“无标题”");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const externalBaseUrl = String(process.env.MNOTE_UI_BASE_URL || "").trim();
|
||||
const useExistingServer = externalBaseUrl.length > 0;
|
||||
const port = useExistingServer ? null : await findFreePort();
|
||||
const baseUrl = useExistingServer ? externalBaseUrl : `http://127.0.0.1:${port}`;
|
||||
const gateway = useExistingServer ? null : startGateway(port);
|
||||
const localFixture = createLocalFolderFixture();
|
||||
const requests = [];
|
||||
let stderr = "";
|
||||
let stdout = "";
|
||||
|
||||
gateway?.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
gateway?.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
await waitForGateway(baseUrl);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("request", (request) => {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = request.postDataJSON?.() ?? null;
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
payload,
|
||||
});
|
||||
});
|
||||
|
||||
let caughtError = null;
|
||||
try {
|
||||
if (!useExistingServer) {
|
||||
await runFixturePhase(page, baseUrl, requests);
|
||||
}
|
||||
await runLocalFolderPhase(page, baseUrl, requests, localFixture);
|
||||
await runSinglePaneTitlePhase(page, baseUrl, localFixture);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
task: "task165-rust-web-dual-pane-smoke",
|
||||
baseUrl,
|
||||
serverMode: useExistingServer ? "existing" : "spawned",
|
||||
requestSummary: {
|
||||
saveRequests: requests.filter(isSaveRequest).length,
|
||||
treeEventRequests: requests.filter(isTreeEventRequest).length,
|
||||
localFolderEventRequests: requests.filter(isLocalFolderEventRequest).length,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
caughtError = error;
|
||||
} finally {
|
||||
await page.close().catch(() => undefined);
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
gateway?.kill("SIGTERM");
|
||||
if (gateway) {
|
||||
setTimeout(() => {
|
||||
if (!gateway.killed) {
|
||||
gateway.kill("SIGKILL");
|
||||
}
|
||||
}, 2000).unref();
|
||||
}
|
||||
fs.rmSync(localFixture.root, { recursive: true, force: true });
|
||||
if (caughtError) {
|
||||
const debug = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
||||
if (debug) {
|
||||
process.stderr.write(`${debug}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (caughtError) {
|
||||
throw caughtError;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user