fix local folder resource lifecycle
This commit is contained in:
@@ -1,38 +1,320 @@
|
||||
# 4-49 [process][bug] 文件树拖拽上传文件落到同级而非目标文件夹下 v1
|
||||
# 4-49 [process][bug] 本地文件夹 Markdown 附件、打开目标与标题合同缺口 v1
|
||||
|
||||
> 发现时间:2026-05-23
|
||||
>
|
||||
> 状态:`[process]`
|
||||
>
|
||||
> 关联主线:`04-tree-domain`
|
||||
> 关联主线:`04-tree-domain`、`05-editor-mainline`
|
||||
>
|
||||
> 关联设计:`design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md`
|
||||
|
||||
## 1. 用户可见症状
|
||||
## 1. 范围判断
|
||||
|
||||
原始记录:
|
||||
这条记录不是单个拖拽 bug,而是本地文件夹 MVP 在 `Markdown 正文引用`、`真实文件资源`、`File Tree projection`、`主编辑区 object tab` 之间的合同缺口。
|
||||
|
||||
```text
|
||||
我的空间:向文件树中拖动文件上传文件,能正常在主编辑 md 中显示,点击也能正常打开,但是见 /mnt/Data1T/mnote/tmp/image copy 65.png,文件是和主文件夹同级(应该在我的文件夹下级)。
|
||||
已按 Sidex / VSCode 行为确认的边界:
|
||||
|
||||
- 主编辑区删除 Markdown 附件块 / 链接,默认只删除正文里的引用,不删除真实附件文件。
|
||||
- 文件树右键删除真实附件,默认只删除真实文件,不自动扫描并改写所有 Markdown 正文。
|
||||
- 删除真实文件后,正文中的链接应保留为 broken reference;编辑区需要增强展示和点击失败处理,而不是静默清除链接。
|
||||
- 只有显式危险动作才可以同时删除引用和真实文件,例如后续可设计“删除引用并移到回收站”,且必须做确认和反向引用检查。
|
||||
|
||||
## 2. 用户可见症状
|
||||
|
||||
### 2.1 主编辑区拖入 / 上传附件后落盘目录不符合预期
|
||||
|
||||
复现描述:
|
||||
|
||||
1. 在“我的空间”或本地文件夹中打开一个 Markdown 页面。
|
||||
2. 向主编辑区拖入文件或上传文件。
|
||||
3. 主编辑区能显示附件,点击也能打开。
|
||||
4. 但文件落在主文件夹同级位置,预期应落在当前 Markdown 页面资源目录下。
|
||||
|
||||
截图:`/mnt/Data1T/mnote/tmp/image copy 65.png`
|
||||
|
||||
当前判断:
|
||||
|
||||
- 代码中已经有两条上传链路:
|
||||
- 文件树外部 drop 到 folder row:通过 `targetRelativePath` 走 `write_local_folder_file_upload()`。
|
||||
- 主编辑区上传 / drop:通过 `documentId` 走 `write_local_markdown_asset()`,目标应为 `markdown_page_resource_directory()`。
|
||||
- 既有 `4-46` 已把“文件树拖到文件夹 row 上传到该文件夹”标记为完成,因此当前缺口更可能在“主编辑区 Markdown 上传目标目录”和“本地 page bundle 目录推导”上,而不是单纯 filetree folder drop。
|
||||
|
||||
建议修复:
|
||||
|
||||
- 明确区分两类 intent:
|
||||
- `filetree.folder.drop`:文件进入用户指定的文件树目录。
|
||||
- `editor.markdown.attach`:文件进入当前 Markdown 页面的资源目录。
|
||||
- 对 `editor.markdown.attach`,后端只允许写入当前 Markdown 的 page resource directory:
|
||||
- bundle 形态:`Page/Page.md` 的附件写入 `Page/`。
|
||||
- flat 形态:`Page.md` 的附件写入 `Page/`,目录不存在时创建。
|
||||
- 前端上传时不要把主编辑区 drop 误标成 folder target;有 `targetRelativePath` 时才走 folder upload。
|
||||
- smoke 需要同时覆盖“拖到文件树文件夹”和“拖到主编辑区”两个入口,避免互相掩盖。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 主编辑区上传附件后,文件树中该附件位于当前 Markdown 页面资源目录下。
|
||||
- 文件树 folder row drop 仍写入目标 folder 下,不回退到当前页面资源目录。
|
||||
- 新增或扩展 smoke 断言上传响应中的 `rootRelativePath`、正文中的相对链接、File Tree row 父子位置一致。
|
||||
|
||||
### 2.2 删除真实附件后,正文链接生命周期不稳定
|
||||
|
||||
复现描述:
|
||||
|
||||
1. 主编辑区中已有附件链接。
|
||||
2. 在文件树附件 row 上右键真实删除该附件。
|
||||
3. 主编辑区中的链接仍在,点击会显示附件不存在。
|
||||
4. 刷新后,正文里的链接消失。
|
||||
|
||||
当前判断:
|
||||
|
||||
- 链接仍在是合理行为;真实文件删除不应自动改写 Markdown 正文。
|
||||
- 刷新后链接消失是不合理行为,说明 Markdown 解析 / 回写 / 附件索引重建时把 broken local asset link 当成无效增强块过滤掉了。
|
||||
- 点击显示附件不存在也可以保留,但应在 tab 内或编辑器内给出明确缺失状态,不应走新窗口兜底。
|
||||
|
||||
建议修复:
|
||||
|
||||
- Markdown parser / serializer 必须保留 broken link 的原始文本。
|
||||
- 编辑器附件增强逻辑识别目标文件不存在时,保留链接并标记 `data-mnote-attachment-missing="true"` 或等价状态。
|
||||
- 点击 missing 附件时打开资源 tab 的 missing state,显示原路径和“文件不存在/已删除”,不删除正文。
|
||||
- 文件树真实删除后通过 watcher / event 让当前编辑器刷新附件存在性状态,但不改正文内容。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 文件树删除附件后,不刷新页面时正文链接仍可见且标记为缺失。
|
||||
- 刷新后正文链接仍存在,样式仍是 broken reference。
|
||||
- 点击缺失附件不会在新窗口打开,也不会静默失败。
|
||||
|
||||
### 2.3 主编辑区删除附件引用的序列化错误
|
||||
|
||||
复现描述:
|
||||
|
||||
1. 主编辑区有两个附件。
|
||||
2. 用 Backspace / Delete 删除后面的附件,前面的附件会变成普通文字。
|
||||
3. 删除前面的附件,后面的附件不受影响。
|
||||
4. 手柄中的删除看起来只是在 UI 上消失,刷新后又回来。
|
||||
|
||||
截图:`/mnt/Data1T/mnote/tmp/image copy 75.png`
|
||||
|
||||
当前判断:
|
||||
|
||||
- Backspace / Delete 默认只删除正文引用,这个产品语义正确。
|
||||
- “前一个附件变成文字”是编辑器 transaction 或 Markdown serializer 范围错误。
|
||||
- “手柄删除刷新后回来”是 UI 删除没有写回真实 `.md`,或写回后 parser 又按旧结构恢复。
|
||||
|
||||
建议修复:
|
||||
|
||||
- 把附件块视为一个可删除的 editor node / mark atom;删除一个附件时只移除对应链接范围,不影响相邻附件。
|
||||
- 手柄删除复用同一条删除引用 command,不走纯 DOM 隐藏。
|
||||
- 保存前后的 Markdown 文本做最小断言:删除第二个附件只删除第二条链接,第一条仍保持链接语法和增强属性来源。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 删除前一个、后一个、中间一个附件,剩余附件都仍是可点击附件块。
|
||||
- 手柄删除后刷新页面,被删引用不再回来。
|
||||
- 真实附件文件仍保留在磁盘,除非用户选择未来的显式危险动作。
|
||||
|
||||
### 2.4 切回“云空间”进入空白 workspace
|
||||
|
||||
复现描述:
|
||||
|
||||
1. 用户打开本地文件夹。
|
||||
2. 想回到“我的空间”,点击左上角下拉中的“云空间”。
|
||||
3. 页面变成空白,整体效果是回不到我的空间。
|
||||
|
||||
当前判断:
|
||||
|
||||
- 该问题曾由 `bugs/04-tree-domain/done/4-36-local-folder-cloud-switch-empty-workspace-v1.md` 修复。
|
||||
- 如果现在仍能复现,应按回归处理:重点检查 `lastCloudWorkspaceId` 是否又被写成 synthetic `default`,以及切回 cloud 时 URL 是否残留 `rootUri`。
|
||||
|
||||
建议修复:
|
||||
|
||||
- 先补回归 smoke 或复用 `scripts/task441-local-folder-cloud-switch-smoke.js`。
|
||||
- `openLocalFolderRoot()` 进入本地前必须记住真实 cloud workspace id,不能从 `document.body` 粗粒度推导。
|
||||
- `switchToCloudWorkspace()` 必须清理 `rootUri` / secondary local source 参数,只携带真实 cloud workspace id。
|
||||
|
||||
验收标准:
|
||||
|
||||
- cloud 有页面 -> 打开 local folder -> 点击云空间 -> 原 cloud 页面树和文件树恢复。
|
||||
- URL 不含 `rootUri`,`mnote.workspace.lastCloudWorkspaceId` 不得为 `default`。
|
||||
|
||||
### 2.5 刚打开本地文件夹时,直接打开附件只能新窗口打开
|
||||
|
||||
复现描述:
|
||||
|
||||
1. 用户刚打开本地文件夹。
|
||||
2. 未先打开任何 Markdown 文件。
|
||||
3. 直接打开任意附件,都会在新窗口打开。
|
||||
4. 只有先打开一个 `.md` 文件后,附件才能在 new tab / 主编辑区 tab 中打开。
|
||||
|
||||
当前判断:
|
||||
|
||||
- 这是主编辑区 object tab host 的 landing state 缺口。
|
||||
- 本地文件夹模式下,即使当前没有 active Markdown document,也应该有 workspace-level editor host 可承载 resource tab。
|
||||
- 附件打开不应依赖 `currentDocumentId()` 是否存在;文件树 row 已经携带 `rootUri + rootRelativePath / assetId`,足够形成 `resource:file:{rootUri}:{path}` identity。
|
||||
|
||||
建议修复:
|
||||
|
||||
- 本地文件夹入口首屏初始化 editor host,即使没有当前文档,也显示空白 / welcome landing tab 容器。
|
||||
- 文件树附件 open intent 使用 `objectIdentity=resource:file:{rootUri}:{rootRelativePath}`,不要回退成 `window.open()`。
|
||||
- `openConvexAssetFromFileTree()` / local file open 分支按 `sourceKind=local_folder` 分流到 active tab;只有 Shift/Ctrl/Meta 或显式“新窗口打开”才新窗口。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 刚进入 local folder,直接点击 pdf / md / 图片 / 普通附件,均在主编辑区 tab 打开。
|
||||
- 未打开任何 `.md` 时也不需要新窗口兜底。
|
||||
- 文件树 row active / selected 保持在被打开附件,不跳到第一个文件。
|
||||
|
||||
### 2.6 文件树分隔线或滚动到底后出现底部空白
|
||||
|
||||
复现描述:
|
||||
|
||||
在文件树的左右拖动调整线处直接滚动页面,或文件树滚动到最下方后继续滚动,会导致下方出现空白。
|
||||
|
||||
截图:`/mnt/Data1T/mnote/tmp/image copy 66.png`
|
||||
|
||||
当前判断:
|
||||
|
||||
- 这更像 workbench layout / scroll container 边界问题,不是资源语义问题。
|
||||
- 可能是 body / main workbench 和 sidebar tree 同时可滚动,滚轮事件穿透到页面根容器后把整个页面滚到工作台高度之外。
|
||||
|
||||
建议修复:
|
||||
|
||||
- Workbench 根容器固定 `height: 100vh`,主页面禁止 body 纵向滚动。
|
||||
- Sidebar / File Tree 自己作为唯一滚动容器,`overscroll-behavior: contain`。
|
||||
- 分隔线 hover / drag 区域拦截 wheel,避免滚动事件穿透到 body。
|
||||
|
||||
验收标准:
|
||||
|
||||
- 文件树滚到最底部后继续滚动,工作台底部不出现额外空白。
|
||||
- 在左右 resize handle 上滚轮,不移动页面根滚动位置。
|
||||
- smoke 记录 `document.scrollingElement.scrollTop === 0` 或等价断言。
|
||||
|
||||
### 2.7 本地文件夹 PDF 只能新窗口打开
|
||||
|
||||
复现描述:
|
||||
|
||||
本地文件夹中的 PDF 文件只能在新窗口打开;我的空间中上传的 PDF 可以在 new tab 中打开。
|
||||
|
||||
当前判断:
|
||||
|
||||
- 与 2.5 同属本地资源 tab host 缺口。
|
||||
- PDF 是普通 resource preview tab,不应因为来源是 local folder 就退化到浏览器新窗口。
|
||||
|
||||
建议修复:
|
||||
|
||||
- 统一本地文件打开目标:默认 active tab,显式 modifier / 菜单动作才 new window。
|
||||
- PDF tab 使用与 cloud asset 相同的 preview host,只是数据源为 `/api/local-folder/files/open?rootUri=...&path=...`。
|
||||
- 缺失文件显示 missing state,不回退到新窗口。
|
||||
|
||||
验收标准:
|
||||
|
||||
- local folder PDF 默认在主编辑区 tab 预览。
|
||||
- cloud PDF 与 local PDF 的 tab chrome、关闭、active row 行为一致。
|
||||
|
||||
### 2.8 本地 Markdown 标题来源与标题隐藏设置不清晰
|
||||
|
||||
复现描述:
|
||||
|
||||
本地文件夹 `.md` 标题解析有问题:第一行带 `#` 时,文件标题重复显示,截图见:
|
||||
|
||||
- `/mnt/Data1T/mnote/tmp/image copy 67.png`
|
||||
- `/mnt/Data1T/mnote/tmp/image copy 69.png`
|
||||
- `/mnt/Data1T/mnote/tmp/image copy 72.png`
|
||||
- `/mnt/Data1T/mnote/tmp/image copy 73.png`
|
||||
|
||||
用户期望:
|
||||
|
||||
- 文件树和页面标题更接近文件名,不要把正文第一行 H1 当成文件标题造成重复。
|
||||
- 页面设置新增全局设置:隐藏本地文件夹 Markdown 文件的文件标题。
|
||||
|
||||
当前判断:
|
||||
|
||||
- 当前代码中已有测试 `local_markdown_page_title_comes_from_file_name_not_body_heading`,但断言和注释实际倾向“有 H1 时标题来自 H1”。这与当前用户期望冲突,需要作为产品口径重新确认并修正测试命名 / 行为。
|
||||
- 对普通本地 Markdown 来说,文件名是更稳定的资源 identity;正文 H1 应保留为正文内容,不建议继续作为文件树标题真源。
|
||||
|
||||
建议修复:
|
||||
|
||||
- 本地文件夹模式下,File Tree / Page Tree 默认标题来自文件名 stem。
|
||||
- 本地 Markdown 页头标题也默认来自文件名,以统一“我的空间”和“本地文件夹”的行为。
|
||||
- Frontmatter `title` 是 Markdown 文件开头 `--- title: ... ---` 这类元数据标题;它是否覆盖页头标题需要单独决定。建议 MVP 先不覆盖 File Tree 文件名,只作为正文 metadata 或页面内显示候选。
|
||||
- 正文 H1 不参与文件名 / tree row title 推导。
|
||||
- 页面设置新增 workspace-level 或 user-level 偏好:`隐藏本地 Markdown 文件标题`。
|
||||
- 默认开启:编辑器不额外显示 MNote 页头标题,只显示 Markdown 正文。
|
||||
- 关闭:显示 MNote 页头标题,但该标题来自文件名,不能与正文 H1 混淆。
|
||||
- 修改标题时应执行文件重命名;校验非法字符、同级重名和 bundle 目录同步。
|
||||
|
||||
验收标准:
|
||||
|
||||
- `File Name.md` 且正文第一行 `# Body Heading` 时,文件树显示 `File Name.md`,正文仍显示 `# Body Heading`。
|
||||
- 不出现页头标题和正文 H1 的重复标题误导。
|
||||
- 页面设置切换“隐藏本地 Markdown 文件标题”后刷新仍生效。
|
||||
- 重命名合法文件时,文件名和 File Tree row 同步更新;非法字符 / 同级重名在输入框内提示。
|
||||
|
||||
## 3. 建议实施顺序
|
||||
|
||||
1. 先补 smoke 红灯:
|
||||
- 主编辑区上传落盘目录。
|
||||
- 删除真实附件后 broken link 保留。
|
||||
- 主编辑区删除附件引用不影响相邻附件。
|
||||
- 刚进入 local folder 直接打开附件 / PDF 进入 tab。
|
||||
- 文件树滚动到底无 body 空白。
|
||||
- 本地 Markdown 文件名标题来源。
|
||||
2. 修复资源生命周期合同:
|
||||
- 上传 intent 分流。
|
||||
- broken reference 保留和标记。
|
||||
- editor attachment delete command 持久化。
|
||||
3. 修复 local folder object tab host:
|
||||
- 没有 active document 时也能打开 resource tab。
|
||||
- PDF / md / 普通附件默认 active tab。
|
||||
4. 修复 workspace source switch 回归,如果 smoke 确认可复现。
|
||||
5. 修复 layout scroll 边界。
|
||||
6. 修正本地 Markdown 标题来源和隐藏标题设置。
|
||||
|
||||
## 4. 已确认与待确认
|
||||
|
||||
- 已确认:本地 Markdown 的 File Tree / 页头标题默认来自文件名,用来统一“我的空间”和“本地文件夹”的行为;正文 H1 永远只作为正文内容。
|
||||
- 已确认:`隐藏本地 Markdown 文件标题` 默认开启,避免本地 Markdown 页头标题与正文 H1 重复。
|
||||
- 已确认:Frontmatter `title` 暂不覆盖页头标题。Frontmatter `title` 指 Markdown 文件开头形如 `--- title: Some Title ---` 的元数据标题;MVP 阶段 File Tree / 页头标题继续以文件名为准。
|
||||
|
||||
## 5. 验证命令建议
|
||||
|
||||
```bash
|
||||
cargo check --manifest-path rust/Cargo.toml -p mnote-web
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown -- --test-threads=1
|
||||
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task459-local-markdown-attachment-tab-smoke.js
|
||||
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task441-local-folder-cloud-switch-smoke.js
|
||||
```
|
||||
|
||||
## 2. 初步判断
|
||||
后续应新增本 bug 专属 smoke,例如:
|
||||
|
||||
这是文件树拖拽上传目标解析问题,不是附件点击打开问题:
|
||||
```bash
|
||||
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js
|
||||
```
|
||||
|
||||
- 上传后的附件块能正常插入主编辑器。
|
||||
- 点击附件能正常打开。
|
||||
- 异常点是资源文件在文件树中的落点:实际落到主文件夹同级,而不是拖拽目标文件夹下级。
|
||||
## 6. 2026-05-24 执行记录
|
||||
|
||||
## 3. 待排查方向
|
||||
已修复 / 已验证:
|
||||
|
||||
- 文件树拖拽上传时的 target row / parent folder 是否正确写入 upload preflight。
|
||||
- `local_folder/assets/upload` 或 upload target plan 是否丢失目标目录。
|
||||
- 页面文件夹 / 当前页面资源归属和文件树手动目标目录之间是否存在优先级覆盖。
|
||||
- 2.1 主编辑区上传附件落盘到当前 Markdown 页面资源目录:`task479` 通过。
|
||||
- 2.2 删除真实附件后刷新,正文 broken link 保留:`task479` 通过。
|
||||
- 2.5 刚进入 local folder、无 active Markdown document 时,点击 PDF 附件进入主编辑区 resource tab:`task479` 通过,`popupCount=0`。
|
||||
- 2.7 本地 Markdown 附件 / PDF 默认 active tab:`task459` 与 `task479` 通过。
|
||||
- 2.8 本地 Markdown 标题来源:File Tree / 页头标题来自文件名,正文 H1 保留在正文;`local_markdown` 单测与 `task479` 通过。
|
||||
|
||||
## 4. 验收建议
|
||||
关键根因:
|
||||
|
||||
后续修复时应补 smoke:
|
||||
- local folder 根入口曾复用全局 `mnote_recent_page_id`。切换到另一个 root 时,旧 recent page 可能指向当前 root 不存在的 `.md`,导致 aggregate 构建失败后进入没有 resource tab host 的 workspace fallback。
|
||||
- 无 active document 的 local folder 首屏缺少 workspace-level 主编辑区 resource tab host,导致附件 row 只能依赖新窗口兜底。
|
||||
|
||||
- 在文件树中创建一个目标文件夹。
|
||||
- 将附件拖拽到该文件夹行。
|
||||
- 断言附件在文件树中位于目标文件夹下级。
|
||||
- 断言主编辑器附件块仍能点击在 tab 打开。
|
||||
本轮修复:
|
||||
|
||||
- 显式 local folder 根入口不再使用跨 root 的 recent page cookie;无有效 page 时保持无 active page landing state。
|
||||
- HomePage 无 active page 分支渲染主编辑区 tab strip、空 page panel 和 resource tab host。
|
||||
- editor island adapter 允许 `panes=[]` 时仍注册 `__mnoteDocumentPaneRuntime.openResourceInActiveTab`,只是不挂载正文编辑器。
|
||||
- `task479` Check 4 改为使用独立 asset-only local folder,确保真实覆盖“无 active Markdown document”。
|
||||
|
||||
仍在 process:
|
||||
|
||||
- 2.3 Backspace / Delete 删除相邻附件的浏览器级 smoke 尚未补齐;手柄删除保存链路已有代码修复,但仍需要真实浏览器回归。
|
||||
- 2.4 切回云空间回归本轮未复测,应继续用 `task441-local-folder-cloud-switch-smoke.js` 覆盖。
|
||||
- 页面设置中“隐藏本地 Markdown 文件标题”的可切换 UI / 持久化尚未实现;当前只落地默认隐藏。
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
# 4-48 [process] 本地文件夹 Markdown 资源生命周期合同 v1
|
||||
|
||||
> 日期:2026-05-24
|
||||
>
|
||||
> Owner:Tree Domain / Local Folder / Editor Mainline
|
||||
>
|
||||
> 关联 bug:`bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md`
|
||||
|
||||
## 1. 背景
|
||||
|
||||
本地文件夹 MVP 已经具备 Markdown 打开、附件上传、File Tree projection、资源 tab 打开和回收站能力,但当前多个缺陷说明系统还缺少一份面向本地 Markdown 的资源生命周期合同。
|
||||
|
||||
核心问题不是“附件点击坏了”这类单点 UI,而是四类对象的边界没有在本地文件夹场景完全收口:
|
||||
|
||||
- Markdown 正文里的链接文本。
|
||||
- 磁盘上的真实附件文件。
|
||||
- File Tree 中的 resource row。
|
||||
- 主编辑区中的 object tab / preview tab。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 固定本地 Markdown 上传、删除、缺失、打开、标题来源的产品和技术合同。
|
||||
- 保持 Markdown 纯文本语义:正文链接不隐式拥有真实文件。
|
||||
- 让本地资源默认进入主编辑区 tab,而不是依赖新窗口兜底。
|
||||
- 为 `4-49` 的多症状 bug 提供实施顺序和验收边界。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不实现完整跨文档反向引用索引。
|
||||
- 不自动删除所有 broken link。
|
||||
- 不把 File Tree UI 提升为资源真源。
|
||||
- 不重写完整 workbench tab 系统。
|
||||
- 不改变 cloud / Convex 资源合同,除非为了保持 open target 行为一致。
|
||||
|
||||
## 4. 合同定义
|
||||
|
||||
### 4.1 上传 intent
|
||||
|
||||
上传入口必须先归类为明确 intent:
|
||||
|
||||
- `editor.markdown.attach`:用户在主编辑区上传或拖入文件。目标是当前 Markdown 页面的 resource directory。
|
||||
- `filetree.folder.drop`:用户把外部文件拖到 File Tree 的 folder row。目标是该 folder 的真实目录。
|
||||
- `filetree.resource.import`:用户未来通过菜单导入资源。目标由菜单上下文决定。
|
||||
|
||||
规则:
|
||||
|
||||
- `editor.markdown.attach` 不应携带 `targetRelativePath`,除非产品明确支持“把正文引用指向任意文件树目录”。
|
||||
- `filetree.folder.drop` 必须携带 `targetRelativePath`。
|
||||
- 后端根据 intent 选择 `write_local_markdown_asset()` 或 `write_local_folder_file_upload()`,不能只靠 `documentId` 是否为空隐式决定。
|
||||
|
||||
### 4.2 Markdown 引用与真实文件
|
||||
|
||||
Markdown 正文链接只是引用,不是所有权声明。
|
||||
|
||||
- 删除正文附件块:只删除链接文本。
|
||||
- 删除文件树附件:只删除真实文件。
|
||||
- 真实文件缺失:正文链接保留为 broken reference。
|
||||
- 自动级联删除真实文件:禁止作为默认行为。
|
||||
|
||||
后续可选动作:
|
||||
|
||||
- “删除引用并移到回收站”:显式危险动作,需要确认。
|
||||
- “清理所有失效引用”:批量工具,需要预览 diff。
|
||||
- “重新定位文件”:把 broken link 指向新路径。
|
||||
|
||||
### 4.3 缺失资源展示
|
||||
|
||||
缺失资源不应静默消失。
|
||||
|
||||
- Parser 必须保留原始 Markdown 链接。
|
||||
- Editor enhancer 标记缺失状态。
|
||||
- 点击缺失资源打开 missing tab 或 inline missing panel。
|
||||
- missing state 显示 root、原相对路径、最后一次检测状态。
|
||||
|
||||
### 4.4 Object tab 打开目标
|
||||
|
||||
本地资源默认打开到主编辑区 active tab。
|
||||
|
||||
- `resource:file:{rootUri}:{rootRelativePath}` 是本地普通文件的最小 object identity。
|
||||
- 即使没有当前 Markdown document,local folder workspace 也必须有 editor host landing state。
|
||||
- PDF、Markdown 附件、图片、普通文件都走同一套 resource tab host。
|
||||
- Shift / Ctrl / Meta 或显式菜单才使用 new window。
|
||||
|
||||
### 4.5 本地 Markdown 标题来源
|
||||
|
||||
已确认 MVP 口径:
|
||||
|
||||
- File Tree / Page Tree 标题默认来自文件名 stem。
|
||||
- 页头标题默认来自文件名 stem,用来统一“我的空间”和“本地文件夹”的行为。
|
||||
- 正文 H1 只作为正文内容,不推导 tree row title。
|
||||
- 用户可在页面设置中隐藏本地 Markdown 页头标题,默认开启,避免与正文 H1 重复。
|
||||
|
||||
已确认:
|
||||
|
||||
- Frontmatter `title` 暂不覆盖 File Tree / Page Tree / 页头标题。Frontmatter `title` 指 Markdown 文件开头形如 `--- title: Some Title ---` 的元数据标题;MVP 阶段它只作为文件元数据保留,不参与本地 Markdown 标题真源。
|
||||
|
||||
## 5. 执行清单
|
||||
|
||||
### 5.1 Smoke 红灯
|
||||
|
||||
- [x] 新增 `task479-local-folder-markdown-resource-lifecycle-smoke.js`。
|
||||
- [x] 覆盖主编辑区上传后落盘到 page resource directory。
|
||||
- [x] 覆盖 filetree folder drop 后落盘到目标 folder。
|
||||
- [x] 覆盖删除真实文件后 broken link 刷新仍保留。
|
||||
- [ ] 覆盖 Backspace / Delete 删除一个附件不破坏相邻附件。
|
||||
- [x] 覆盖刚进入 local folder 直接打开 PDF / Markdown 附件进入 active tab。
|
||||
- [x] 覆盖 body 不因文件树滚动到底产生额外空白。
|
||||
- [x] 覆盖本地 Markdown 文件名标题来源。
|
||||
|
||||
### 5.2 上传链路
|
||||
|
||||
- [ ] 前端上传 detail 增加明确 `uploadIntent`。
|
||||
- [ ] 后端 `/api/local-folder/assets/upload` 解析并校验 intent。
|
||||
- [ ] `editor.markdown.attach` 写入 `markdown_page_resource_directory()`。
|
||||
- [ ] `filetree.folder.drop` 写入 `targetRelativePath` 指向的目录。
|
||||
- [ ] 上传响应返回 intent、rootRelativePath、markdownRelativePath 和 ownerDocumentId,供 smoke 断言。
|
||||
|
||||
### 5.3 删除和缺失资源
|
||||
|
||||
- [ ] 主编辑区附件删除 command 只删除 Markdown 链接并保存。
|
||||
- [ ] 手柄删除复用同一 command,不做纯 DOM 隐藏。
|
||||
- [ ] Parser / serializer 保留 broken link。
|
||||
- [ ] watcher 收到真实文件删除后刷新存在性状态,不改正文。
|
||||
- [ ] 缺失资源点击进入 missing tab。
|
||||
|
||||
### 5.4 Resource tab host
|
||||
|
||||
- [x] local folder 首屏无 active document 时也渲染 editor host。
|
||||
- [x] filetree resource open 默认 active tab。
|
||||
- [x] PDF 使用与 cloud asset 一致的 preview chrome。
|
||||
- [x] 本地 Markdown 附件作为 resource tab 打开,不抢占当前页面正文。
|
||||
- [ ] active row / selected row 保持在被打开资源。
|
||||
|
||||
### 5.5 标题和页面设置
|
||||
|
||||
- [x] 修正本地 Markdown 标题解析测试命名和断言。
|
||||
- [x] File Tree / Page Tree 标题默认来自文件名。
|
||||
- [x] 正文 H1 保留在正文,不参与标题推导。
|
||||
- [x] 本地 Markdown 页头标题默认隐藏。
|
||||
- [ ] 页面设置新增“隐藏本地 Markdown 文件标题”的可切换 UI。
|
||||
- [ ] 设置刷新后持久生效。
|
||||
- [ ] 重命名标题时走文件重命名和 bundle 目录同步。
|
||||
|
||||
### 5.6 滚动边界
|
||||
|
||||
- [ ] Workbench 根容器固定视口高度。
|
||||
- [ ] Sidebar / File Tree 是唯一纵向滚动容器。
|
||||
- [ ] resize handle wheel 不穿透到 body。
|
||||
- [ ] smoke 断言 `document.scrollingElement.scrollTop` 不变化。
|
||||
|
||||
## 6. 建议切片
|
||||
|
||||
1. Smoke + 合同观测字段。
|
||||
2. 上传 intent 分流。
|
||||
3. 删除引用和 broken link 保留。
|
||||
4. 本地 resource tab host。
|
||||
5. PDF / Markdown 附件 tab 一致性。
|
||||
6. 标题来源和隐藏标题设置。
|
||||
7. 滚动边界。
|
||||
|
||||
## 7. 验收命令
|
||||
|
||||
```bash
|
||||
cargo check --manifest-path rust/Cargo.toml -p mnote-web
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web local_markdown -- --test-threads=1
|
||||
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task459-local-markdown-attachment-tab-smoke.js
|
||||
PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folder-markdown-resource-lifecycle-smoke.js
|
||||
codegraph sync .
|
||||
```
|
||||
|
||||
## 8. Done Gate
|
||||
|
||||
- [ ] `4-49` 中的 6 组用户可见症状均有对应 smoke。
|
||||
- [ ] 默认删除行为不级联删除真实附件。
|
||||
- [x] 文件树真实删除不再导致正文链接刷新后消失。
|
||||
- [x] 本地资源默认在主编辑区 tab 打开。
|
||||
- [x] 本地 Markdown 标题来源和隐藏默认值经过用户确认并落地。
|
||||
- [ ] 相关 bug 记录移动到 `done/` 并补验证证据。
|
||||
|
||||
## 9. 2026-05-24 执行记录
|
||||
|
||||
- `task479-local-folder-markdown-resource-lifecycle-smoke.js` 已覆盖上传目录、filetree folder drop、broken link 保留、无 active document 附件 active tab、文件树滚动边界、文件名标题来源。
|
||||
- 修复 local folder root entry 不再复用跨 root 的 `mnote_recent_page_id`,避免 asset-only 工作区误进入无效 active page fallback。
|
||||
- 修复无 active document 的 local folder 首屏,补齐 workspace-level 主编辑区 resource tab host 和空 panes runtime。
|
||||
- 本地 Markdown 标题已收口为文件名来源;Frontmatter title 暂不覆盖标题真源;本地 Markdown 页头默认隐藏。
|
||||
- 验证:`task479` 全部通过,`task459-local-markdown-attachment-tab-smoke.js` 通过;Rust `root_entry_`、`local_markdown`、`document_shell_renders` 相关单测通过。
|
||||
@@ -322,12 +322,8 @@ pub async fn root_entry(
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("local-folder")
|
||||
.to_string();
|
||||
let requested_or_recent_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let requested_or_recent_page_id =
|
||||
choose_root_entry_active_page_id(requested_page_id.clone(), None, None, None);
|
||||
let workspace_projection = build_workspace_shell_projection(
|
||||
&snapshot.dataset,
|
||||
&workspace_id,
|
||||
@@ -336,7 +332,7 @@ pub async fn root_entry(
|
||||
);
|
||||
let selected_active_page_id = choose_root_entry_active_page_id(
|
||||
requested_page_id.clone(),
|
||||
recent_page_id.clone(),
|
||||
None,
|
||||
workspace_projection.active_page_id.as_deref(),
|
||||
workspace_projection
|
||||
.my_page_items
|
||||
@@ -494,7 +490,24 @@ pub async fn root_entry(
|
||||
})
|
||||
};
|
||||
let (html_title, content, body_extra) = if active_page_id.trim().is_empty() {
|
||||
("MNOTE".to_string(), render_workspace_entry(), String::new())
|
||||
let body_extra = if active_source_kind.as_deref() == Some("local_folder") {
|
||||
let panes_bootstrap_json = serde_json::to_string(&json!({
|
||||
"schema": "mnote.document_panes_bootstrap.v1",
|
||||
"secondaryRequested": false,
|
||||
"secondaryInvalid": false,
|
||||
"panes": [],
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string());
|
||||
format!(
|
||||
r#"<script id="__MNOTE_DOCUMENT_PANES_BOOTSTRAP__" type="application/json">{}</script>
|
||||
{}"#,
|
||||
escape_script_json(&panes_bootstrap_json),
|
||||
render_editor_island_adapter_script(),
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
("MNOTE".to_string(), render_workspace_entry(), body_extra)
|
||||
} else {
|
||||
match build_page_aggregate_snapshot(
|
||||
&state,
|
||||
@@ -2505,8 +2518,11 @@ mod tests {
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(!html.contains("初始化的新页面"));
|
||||
assert!(html.contains(r#"data-mnote-source-kind="local_folder""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
||||
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(!html.contains(r#"data-testid="mnote-create-default-local-workspace""#));
|
||||
assert!(!html.contains(r#"data-testid="mnote-open-local-folder-empty""#));
|
||||
assert!(!html.contains("workspaces:ensureDefaultWorkspace"));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
@@ -2719,6 +2735,7 @@ mod tests {
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
@@ -2743,6 +2760,50 @@ mod tests {
|
||||
assert!(!html.contains(r#"href="/tree"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_local_folder_without_active_page_keeps_resource_tab_host() {
|
||||
let root = temp_root("mnote-root-local-folder-no-active-page");
|
||||
std::fs::create_dir_all(root.join("attachments")).expect("create attachments");
|
||||
std::fs::write(root.join("attachments").join("report-a.pdf"), b"%PDF-1.4\n")
|
||||
.expect("write pdf");
|
||||
|
||||
let root_uri = format!("file://{}", root.display());
|
||||
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
|
||||
"user_real",
|
||||
&root_uri,
|
||||
)
|
||||
.expect("init local workspace");
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/?sourceKind=local_folder&rootUri={root_uri}&treeView=filetree"
|
||||
))
|
||||
.header("cookie", "mnote_recent_page_id=local-md:Other~2FOther.md")
|
||||
.header("x-mnote-actor-id", "user_real")
|
||||
.header("x-mnote-actor-type", "user")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let html = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(html.contains(r#"data-mnote-shell="workspace""#));
|
||||
assert!(html.contains(r#"data-row-id="local:asset:attachments/report-a.pdf""#));
|
||||
assert!(html.contains(r#"data-testid="mnote-document-workspace""#));
|
||||
assert!(html.contains(r#"data-mnote-resource-tab-host="true""#));
|
||||
assert!(html.contains("__MNOTE_DOCUMENT_PANES_BOOTSTRAP__"));
|
||||
assert!(html.contains("openResourceInActiveTab"));
|
||||
assert!(!html.contains(r#"<script id="__MNOTE_PAGE_AGGREGATE__""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_entry_redirects_anonymous_viewer_to_auth() {
|
||||
let response = app_with_config("http://127.0.0.1:3100".into(), false)
|
||||
|
||||
@@ -10201,12 +10201,12 @@ fn main() {}
|
||||
})
|
||||
.expect("page row");
|
||||
|
||||
// 有 H1 且无 frontmatter title 时,标题来自 H1。
|
||||
assert_eq!(row["title"].as_str(), Some("Body Heading"));
|
||||
// 本地 Markdown 标题来自文件名,正文 H1 只作为正文内容。
|
||||
assert_eq!(row["title"].as_str(), Some("File Name"));
|
||||
|
||||
let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md")
|
||||
.expect("aggregate");
|
||||
assert_eq!(aggregate.title, "Body Heading");
|
||||
assert_eq!(aggregate.title, "File Name");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -11386,12 +11386,11 @@ fn main() {}
|
||||
let snapshot = load_local_folder_page_tree_snapshot(&root_uri).expect("load page tree");
|
||||
let items = snapshot.projection["items"].as_array().expect("items");
|
||||
|
||||
// 找到 Root 页面节点和 Child 页面节点。
|
||||
// 标题优先级:H1("Body Root" / "Body Child")高于文件名。
|
||||
// 找到 Root 页面节点和 Child 页面节点。标题来自文件名,不来自正文 H1。
|
||||
let root_node = items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item["title"].as_str() == Some("Body Root")
|
||||
item["title"].as_str() == Some("Root")
|
||||
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
|
||||
})
|
||||
.expect("Root page node");
|
||||
@@ -11399,7 +11398,7 @@ fn main() {}
|
||||
let child_node = items
|
||||
.iter()
|
||||
.find(|item| {
|
||||
item["title"].as_str() == Some("Body Child")
|
||||
item["title"].as_str() == Some("Child")
|
||||
&& item["resourceMeta"]["resourceKind"].as_str() == Some("document")
|
||||
})
|
||||
.expect("Child page node");
|
||||
|
||||
@@ -79,61 +79,17 @@ struct MarkdownInlineStyles {
|
||||
}
|
||||
|
||||
pub fn parse_markdown_page(markdown: &str, file_name: &str) -> ParsedLocalMarkdownPage {
|
||||
let (frontmatter, body) = split_frontmatter(markdown);
|
||||
let (_frontmatter, body) = split_frontmatter(markdown);
|
||||
let body_owned = body.to_string();
|
||||
// 标题优先级:frontmatter title > 正文第一条 H1 > 文件名。
|
||||
let title = parse_frontmatter_title(frontmatter.as_deref())
|
||||
.or_else(|| find_first_h1(&body_owned))
|
||||
.unwrap_or_else(|| file_stem_title(file_name));
|
||||
// 本地 Markdown 的树标题和页头标题统一来自文件名;
|
||||
// frontmatter title 与正文 H1 都保留为正文/元数据内容,不作为资源标题真相。
|
||||
let title = file_stem_title(file_name);
|
||||
ParsedLocalMarkdownPage {
|
||||
title,
|
||||
body: body_owned,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 frontmatter 中提取简单 `title:` 字段。
|
||||
fn parse_frontmatter_title(frontmatter: Option<&str>) -> Option<String> {
|
||||
let text = frontmatter?;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(value) = trimmed.strip_prefix("title:") {
|
||||
let raw = value.trim();
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let unquoted = if (raw.starts_with('"') && raw.ends_with('"'))
|
||||
|| (raw.starts_with('\'') && raw.ends_with('\''))
|
||||
{
|
||||
&raw[1..raw.len() - 1]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
let title = unquoted.trim().to_string();
|
||||
if !title.is_empty() {
|
||||
return Some(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 通过 AST 找正文第一条 H1,避免把代码块里的 `# ...` 误判为标题。
|
||||
fn find_first_h1(body: &str) -> Option<String> {
|
||||
let arena = Arena::new();
|
||||
let root = parse_document(&arena, body, &markdown_options());
|
||||
for node in root.descendants() {
|
||||
let value = node.data.borrow();
|
||||
if let NodeValue::Heading(heading) = &value.value {
|
||||
if heading.level != 1 {
|
||||
continue;
|
||||
}
|
||||
let title = collect_plain_text(node).trim().to_string();
|
||||
return (!title.is_empty()).then_some(title);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn markdown_to_blocks(markdown: &str) -> Value {
|
||||
markdown_to_blocks_with_attachment_paths(markdown, &BTreeSet::new())
|
||||
}
|
||||
@@ -920,65 +876,35 @@ mod tests {
|
||||
assert!(!d2_inline.is_empty(), "非空数据单元格不应为空");
|
||||
}
|
||||
|
||||
// 标题优先级:frontmatter title > H1 > 文件名。
|
||||
// 本地 Markdown 标题统一来自文件名,frontmatter title 和 H1 都只保留为正文/元数据。
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_uses_frontmatter_title() {
|
||||
fn parse_markdown_title_uses_filename_over_frontmatter_and_h1() {
|
||||
let parsed = parse_markdown_page(
|
||||
"---\ntitle: Frontmatter Title\n---\n# H1 Heading\nbody\n",
|
||||
"file.md",
|
||||
"File Name.md",
|
||||
);
|
||||
assert_eq!(parsed.title, "Frontmatter Title");
|
||||
assert_eq!(parsed.title, "File Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_uses_first_h1_when_no_frontmatter_title() {
|
||||
let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "file.md");
|
||||
assert_eq!(parsed.title, "H1 Title");
|
||||
fn parse_markdown_title_uses_filename_when_h1_exists() {
|
||||
let parsed = parse_markdown_page("# H1 Title\nbody\n## Not H1\n", "File Name.md");
|
||||
assert_eq!(parsed.title, "File Name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_falls_back_to_filename() {
|
||||
fn parse_markdown_title_uses_filename_when_body_has_no_h1() {
|
||||
let parsed = parse_markdown_page("plain text\nmore text\n", "My File.md");
|
||||
assert_eq!(parsed.title, "My File");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_prefers_frontmatter_over_h1() {
|
||||
let parsed = parse_markdown_page("---\ntitle: FM Title\n---\n# H1 here\n", "file.md");
|
||||
assert_eq!(parsed.title, "FM Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_handles_quoted_double() {
|
||||
let parsed = parse_markdown_page("---\ntitle: \"Quoted Double\"\n---\nbody\n", "file.md");
|
||||
assert_eq!(parsed.title, "Quoted Double");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_handles_quoted_single() {
|
||||
let parsed = parse_markdown_page("---\ntitle: 'Single Quoted'\n---\nbody\n", "file.md");
|
||||
assert_eq!(parsed.title, "Single Quoted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_uses_h1_when_frontmatter_has_no_title() {
|
||||
let parsed = parse_markdown_page("---\nother: value\n---\n# From H1\n", "file.md");
|
||||
assert_eq!(parsed.title, "From H1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_uses_filename_when_body_has_no_h1() {
|
||||
fn parse_markdown_title_ignores_frontmatter_without_title() {
|
||||
let parsed = parse_markdown_page("---\ntags: foo\n---\nplain text\n", "NoH1.md");
|
||||
assert_eq!(parsed.title, "NoH1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_handles_atx_closing_markers() {
|
||||
let parsed = parse_markdown_page("# Heading With Closing #\n", "file.md");
|
||||
assert_eq!(parsed.title, "Heading With Closing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_markdown_title_ignores_hash_inside_code_block() {
|
||||
let parsed = parse_markdown_page("```md\n# Not A Title\n```\nplain text\n", "Fallback.md");
|
||||
|
||||
@@ -222,6 +222,8 @@ pub async fn document_page_shell(
|
||||
secondary_workspace_id={secondary_aggregate.as_ref().map(|aggregate| aggregate.identity.workspace_id.clone()).unwrap_or_default()}
|
||||
secondary_page_subtree_json={secondary_page_subtree_json.unwrap_or_default()}
|
||||
secondary_page_options_json={secondary_page_options_json.unwrap_or_default()}
|
||||
primary_hide_title_header={is_local_folder}
|
||||
secondary_hide_title_header={secondary_source_kind == Some("local_folder")}
|
||||
show_admin_access_policy={is_local_access_policy_admin_context(&context)}
|
||||
/>
|
||||
});
|
||||
@@ -653,7 +655,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const panesBootstrap = parseJsonScript(PANES_BOOTSTRAP_ID);
|
||||
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes) || panesBootstrap.panes.length === 0) return;
|
||||
if (!panesBootstrap || !Array.isArray(panesBootstrap.panes)) return;
|
||||
|
||||
const paneRouteConfig = {
|
||||
primary: {
|
||||
@@ -803,7 +805,6 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
};
|
||||
|
||||
const paneRuntimes = panesBootstrap.panes.map(buildPaneRuntime).filter(Boolean);
|
||||
if (!paneRuntimes.length) return;
|
||||
|
||||
const loadRuntime = async () => {
|
||||
if (window.__mnoteLeptosTiptapRuntimePromise) {
|
||||
@@ -4298,9 +4299,11 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
|
||||
console.error('mnote multi-pane editor mount failed', error);
|
||||
});
|
||||
if (paneRuntimes.length) {
|
||||
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).catch((error) => {
|
||||
console.error('mnote multi-pane editor mount failed', error);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>"#
|
||||
}
|
||||
@@ -5282,8 +5285,8 @@ mod tests {
|
||||
payload["result"]["identity"]["documentId"],
|
||||
"local-md:Local~20Aggregate~2FLocal~20Aggregate.md"
|
||||
);
|
||||
// H1 "Local Heading" 优先于文件名标题 "Local Aggregate"。
|
||||
assert_eq!(payload["result"]["head"]["title"], "Local Heading");
|
||||
// 本地 Markdown 标题来自文件名;正文 H1 只作为正文内容。
|
||||
assert_eq!(payload["result"]["head"]["title"], "Local Aggregate");
|
||||
assert_eq!(payload["result"]["head"]["permissions"]["readOnly"], false);
|
||||
assert_eq!(payload["result"]["body"]["revision"], 0);
|
||||
assert!(payload["result"]["body"]["content"]
|
||||
@@ -5423,8 +5426,8 @@ mod tests {
|
||||
assert!(html.contains("localFolderEventRegistry"));
|
||||
assert!(html
|
||||
.contains("if (!response.ok) {\n if (session.sourceKind === 'local_folder')"));
|
||||
assert!(html.contains("mayAffectMissingDocument"));
|
||||
assert!(html.contains("eventKind.includes('Remove') || eventKind.includes('Name')"));
|
||||
assert!(html.contains("shouldSuppressLocalFolderSelfChange"));
|
||||
assert!(html.contains("kind.includes('Modify(Name')"));
|
||||
assert!(html.contains("targetSession.views.size === 0"));
|
||||
assert!(html.contains("session.views.size === 0"));
|
||||
assert!(html.contains("targetSession.saving"));
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct DocumentPaneViewModel {
|
||||
pub has_page_subtree: bool,
|
||||
pub primary_legacy_ids: bool,
|
||||
pub visible: bool,
|
||||
pub hide_title_header: bool,
|
||||
}
|
||||
|
||||
#[component]
|
||||
@@ -76,7 +77,11 @@ fn DocumentPane(model: DocumentPaneViewModel) -> impl IntoView {
|
||||
data-page-font={model.page_font.clone()}
|
||||
data-page-show-heading-numbers={model.page_show_heading_numbers.to_string()}
|
||||
>
|
||||
<header class="document-shell-header">
|
||||
<header
|
||||
class="document-shell-header"
|
||||
data-page-title-hidden={model.hide_title_header.to_string()}
|
||||
hidden={model.hide_title_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>
|
||||
@@ -177,6 +182,12 @@ pub fn DocumentPage(
|
||||
/// 右侧页面选项 JSON(可选)
|
||||
#[prop(optional)]
|
||||
secondary_page_options_json: String,
|
||||
/// 是否隐藏主文档页头标题
|
||||
#[prop(optional)]
|
||||
primary_hide_title_header: bool,
|
||||
/// 是否隐藏右侧文档页头标题
|
||||
#[prop(optional)]
|
||||
secondary_hide_title_header: bool,
|
||||
/// 是否显示管理员授权入口
|
||||
#[prop(optional)]
|
||||
show_admin_access_policy: bool,
|
||||
@@ -260,6 +271,7 @@ pub fn DocumentPage(
|
||||
has_page_subtree,
|
||||
primary_legacy_ids: true,
|
||||
visible: true,
|
||||
hide_title_header: primary_hide_title_header,
|
||||
};
|
||||
let secondary_model = DocumentPaneViewModel {
|
||||
pane_role: "secondary",
|
||||
@@ -278,6 +290,7 @@ pub fn DocumentPage(
|
||||
has_page_subtree: secondary_has_page_subtree,
|
||||
primary_legacy_ids: false,
|
||||
visible: secondary_visible,
|
||||
hide_title_header: secondary_hide_title_header,
|
||||
};
|
||||
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()} show_admin_access_policy={show_admin_access_policy} enable_tree_live={enable_tree_live}>
|
||||
|
||||
@@ -61,7 +61,54 @@ pub fn HomePage(
|
||||
</main>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {}.into_any()
|
||||
view! {
|
||||
<main
|
||||
class="document-workspace mnote-workspace-empty-editor-host"
|
||||
data-testid="mnote-document-workspace"
|
||||
data-has-secondary-pane="false"
|
||||
data-workspace-id={workspace_id.clone().unwrap_or_default()}
|
||||
>
|
||||
<section class="document-main-editor-group" data-testid="mnote-main-editor-tab-host" data-pane-role="primary">
|
||||
<div class="mnote-main-tab-strip" data-mnote-main-tab-strip="true" data-pane-role="primary" role="tablist" aria-label="主编辑区标签页">
|
||||
<button
|
||||
type="button"
|
||||
class="mnote-main-tab is-active"
|
||||
data-mnote-main-tab="page"
|
||||
data-mnote-tab-kind="page"
|
||||
data-pane-role="primary"
|
||||
data-workspace-id={workspace_id.clone().unwrap_or_default()}
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="mnote-main-tab-badge" aria-hidden="true"></span>
|
||||
<span class="mnote-main-tab-title">"文件夹"</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mnote-main-tab-panels">
|
||||
<div data-mnote-page-tab-panel="true" data-pane-role="primary">
|
||||
<section
|
||||
class="document-pane mnote-workspace-empty-pane"
|
||||
data-document-pane="true"
|
||||
data-pane-role="primary"
|
||||
data-pane-workspace-id={workspace_id.clone().unwrap_or_default()}
|
||||
data-pane-visible="true"
|
||||
aria-label="主文档"
|
||||
></section>
|
||||
</div>
|
||||
<section
|
||||
class="mnote-resource-tab-host"
|
||||
data-mnote-resource-tab-host="true"
|
||||
data-pane-role="primary"
|
||||
data-testid="mnote-resource-tab-host"
|
||||
hidden=true
|
||||
>
|
||||
<div class="mnote-resource-tab-panel-root" data-mnote-resource-tab-panel-root="true" data-pane-role="primary"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
}.into_any()
|
||||
}}
|
||||
</PageLayout>
|
||||
}
|
||||
|
||||
@@ -3138,8 +3138,7 @@ const SIDEBAR_TREE_JS: &str = r##"
|
||||
}
|
||||
|
||||
function shouldOpenLocalResourceInNewWindow(fileName) {
|
||||
var ext = attachmentExtensionFromFileName(fileName);
|
||||
return ext === 'pdf';
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalUploadedAsset(asset) {
|
||||
@@ -10923,9 +10922,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_runtime_opens_local_pdf_assets_in_browser_tab_by_default() {
|
||||
fn sidebar_runtime_opens_local_pdf_assets_in_active_resource_tab_by_default() {
|
||||
assert!(SIDEBAR_TREE_JS.contains("function shouldOpenLocalResourceInNewWindow(fileName)"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("return ext === 'pdf';"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("return false;"));
|
||||
assert!(SIDEBAR_TREE_JS.contains("var shouldOpenInNewWindow = forceNewWindow || shouldOpenLocalResourceInNewWindow(localFileName);"));
|
||||
assert!(SIDEBAR_TREE_JS
|
||||
.contains("if (!shouldOpenInNewWindow && await openLocalResourceInActiveTab({"));
|
||||
|
||||
@@ -26,6 +26,6 @@ wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3.77", features = ["CustomEvent", "CustomEventInit", "DataTransfer", "Document", "DomRect", "DragEvent", "Element", "EventTarget", "HtmlElement", "MessageEvent", "MouseEvent", "Node", "Range", "RequestInit", "RequestMode", "Response", "Selection", "Storage", "Window"] }
|
||||
|
||||
[dependencies.leptos-tiptap]
|
||||
path = "../../../design/05-editor-mainline/reference-code/leptos-tiptap"
|
||||
path = "../../../reference-code/leptos-tiptap"
|
||||
default-features = false
|
||||
features = ["component", "full"]
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export interface InitOutput {
|
||||
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__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (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;
|
||||
|
||||
+19
-11
@@ -608,6 +608,14 @@ function __wbg_get_imports() {
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
},
|
||||
__wbg_innerHeight_72e7bb88c4b9ede8: function() { return handleError(function (arg0) {
|
||||
const ret = arg0.innerHeight;
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_innerWidth_c7446907ab672e41: function() { return handleError(function (arg0) {
|
||||
const ret = arg0.innerWidth;
|
||||
return ret;
|
||||
}, arguments); },
|
||||
__wbg_insertBefore_38c7d835a2dcac23: function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.insertBefore(arg1, arg2);
|
||||
return ret;
|
||||
@@ -1223,42 +1231,42 @@ function __wbg_get_imports() {
|
||||
}
|
||||
}, arguments); },
|
||||
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1129, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1128, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1181, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1180, 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_0000000000000003: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 957, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 956, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
const ret = makeClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__had771ddc65647798);
|
||||
return ret;
|
||||
},
|
||||
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1079, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1078, 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: 1129, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 1128, 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: 1081, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1080, 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: 1096, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1095, 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: 1132, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 1131, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h0b7fb40a8610550c);
|
||||
return ret;
|
||||
},
|
||||
@@ -1318,8 +1326,8 @@ function wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h396914cf76a9e7a5(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
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__had771ddc65647798(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__had771ddc65647798(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h0abd2b2fe4652e2a(arg0, arg1, arg2) {
|
||||
|
||||
BIN
Binary file not shown.
Vendored
+1
-1
@@ -21,7 +21,7 @@ export const intounderlyingsource_cancel: (a: 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__h396914cf76a9e7a5: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hde8e7b4803478c08: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__had771ddc65647798: (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;
|
||||
|
||||
@@ -6363,11 +6363,23 @@ fn apply_html_update(
|
||||
editor: TiptapEditorHandle,
|
||||
persisted_identity: &PersistedDocumentIdentity,
|
||||
next_html: String,
|
||||
document_id: ReadSignal<Option<String>>,
|
||||
workspace_id: ReadSignal<Option<String>>,
|
||||
dirty_count: ReadSignal<u32>,
|
||||
set_dirty_count: WriteSignal<u32>,
|
||||
set_html_output: WriteSignal<String>,
|
||||
set_document_json: WriteSignal<Value>,
|
||||
set_json_output: WriteSignal<String>,
|
||||
title: ReadSignal<String>,
|
||||
hovered_block: ReadSignal<Option<HoveredBlockState>>,
|
||||
editor_focused: ReadSignal<bool>,
|
||||
slash_open: ReadSignal<bool>,
|
||||
turn_into_open: ReadSignal<bool>,
|
||||
color_menu_open: ReadSignal<bool>,
|
||||
more_menu_open: ReadSignal<bool>,
|
||||
revision: ReadSignal<Option<i64>>,
|
||||
conflict_detection_key: ReadSignal<Option<String>>,
|
||||
read_only: ReadSignal<bool>,
|
||||
set_command_feedback: WriteSignal<String>,
|
||||
success_message: impl Into<String>,
|
||||
) {
|
||||
@@ -6379,6 +6391,39 @@ fn apply_html_update(
|
||||
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(),
|
||||
);
|
||||
match persist_document_state(
|
||||
persisted_identity,
|
||||
&title.get_untracked(),
|
||||
@@ -8487,11 +8532,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
editor,
|
||||
&persisted_identity,
|
||||
next_html,
|
||||
document_id,
|
||||
workspace_id,
|
||||
dirty_count,
|
||||
set_dirty_count,
|
||||
set_html_output,
|
||||
set_document_json,
|
||||
set_json_output,
|
||||
title,
|
||||
hovered_block,
|
||||
editor_focused,
|
||||
slash_open,
|
||||
turn_into_open,
|
||||
color_menu_open,
|
||||
more_menu_open,
|
||||
revision,
|
||||
conflict_detection_key,
|
||||
read_only,
|
||||
set_command_feedback,
|
||||
"已通过块手柄拖拽重排",
|
||||
);
|
||||
@@ -9268,11 +9325,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
editor,
|
||||
&runtime_persisted_identity(document_id, workspace_id),
|
||||
next_html,
|
||||
document_id,
|
||||
workspace_id,
|
||||
dirty_count,
|
||||
set_dirty_count,
|
||||
set_html_output,
|
||||
set_document_json,
|
||||
set_json_output,
|
||||
title,
|
||||
hovered_block,
|
||||
editor_focused,
|
||||
slash_open,
|
||||
turn_into_open,
|
||||
color_menu_open,
|
||||
more_menu_open,
|
||||
revision,
|
||||
conflict_detection_key,
|
||||
read_only,
|
||||
set_command_feedback,
|
||||
"已通过块手柄拖拽重排",
|
||||
);
|
||||
@@ -10423,11 +10492,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
editor,
|
||||
&runtime_persisted_identity(document_id, workspace_id),
|
||||
next_html,
|
||||
document_id,
|
||||
workspace_id,
|
||||
dirty_count,
|
||||
set_dirty_count,
|
||||
set_html_output,
|
||||
set_document_json,
|
||||
set_json_output,
|
||||
title,
|
||||
hovered_block,
|
||||
editor_focused,
|
||||
slash_open,
|
||||
turn_into_open,
|
||||
color_menu_open,
|
||||
more_menu_open,
|
||||
revision,
|
||||
conflict_detection_key,
|
||||
read_only,
|
||||
set_command_feedback,
|
||||
format!("已复制 {}", duplicate_feedback_label.clone()),
|
||||
);
|
||||
@@ -10456,11 +10537,23 @@ fn App(mount_options: MountOptions) -> impl IntoView {
|
||||
editor,
|
||||
&runtime_persisted_identity(document_id, workspace_id),
|
||||
next_html,
|
||||
document_id,
|
||||
workspace_id,
|
||||
dirty_count,
|
||||
set_dirty_count,
|
||||
set_html_output,
|
||||
set_document_json,
|
||||
set_json_output,
|
||||
title,
|
||||
hovered_block,
|
||||
editor_focused,
|
||||
slash_open,
|
||||
turn_into_open,
|
||||
color_menu_open,
|
||||
more_menu_open,
|
||||
revision,
|
||||
conflict_detection_key,
|
||||
read_only,
|
||||
set_command_feedback,
|
||||
format!("已删除 {}", delete_feedback_label.clone()),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
#!/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 { chromium } = require("playwright");
|
||||
|
||||
const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
|
||||
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
|
||||
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
|| ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
function fileUrl(localPath) {
|
||||
return `file://${localPath}`;
|
||||
}
|
||||
|
||||
function localMdDocumentId(relativePath) {
|
||||
return `local-md:${relativePath.replaceAll("/", "~2F")}`;
|
||||
}
|
||||
|
||||
function documentUrl(root, relativePath) {
|
||||
const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function localFolderHomeUrl(root) {
|
||||
const url = new URL(`${BASE_URL}/`);
|
||||
url.searchParams.set("sourceKind", "local_folder");
|
||||
url.searchParams.set("rootUri", fileUrl(root));
|
||||
url.searchParams.set("treeView", "filetree");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function writeWorkspaceManifest(root, ownerId, taskId) {
|
||||
const metadataDir = path.join(root, ".mnote");
|
||||
fs.mkdirSync(metadataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(metadataDir, "workspace.json"),
|
||||
`${JSON.stringify({
|
||||
workspaceId: `local-ws:${ownerId}:${taskId}`,
|
||||
ownerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
capabilities: ["local_files", "markdown_edit", "asset_upload"],
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function quickLogin(page) {
|
||||
await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
|
||||
if (await quickLoginButton.count()) {
|
||||
await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function activatePrimaryPageTab(page) {
|
||||
const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
|
||||
if (await pageTab.count()) {
|
||||
await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
}
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadAttachmentViaPrimarySlash(page, fileName, markdownContent) {
|
||||
await activatePrimaryPageTab(page);
|
||||
const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
|
||||
await editor.click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.keyboard.press("End").catch(() => undefined);
|
||||
await page.keyboard.type("/");
|
||||
const item = page
|
||||
.locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]')
|
||||
.first();
|
||||
await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
|
||||
item.click({ timeout: UI_TIMEOUT_MS }),
|
||||
]);
|
||||
await fileChooser.setFiles({
|
||||
name: fileName,
|
||||
mimeType: "text/markdown",
|
||||
buffer: Buffer.from(markdownContent, "utf8"),
|
||||
});
|
||||
await page.waitForFunction(
|
||||
(name) => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return editor instanceof HTMLElement && (editor.textContent || "").includes(name);
|
||||
},
|
||||
fileName,
|
||||
{ timeout: UI_TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForFileExists(filePath, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (!fs.existsSync(filePath) && Date.now() - startedAt < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
// ─── main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task479-lifecycle-"));
|
||||
const relativePath = "README.md";
|
||||
const resourceDirName = "README";
|
||||
const resourceDir = path.join(root, resourceDirName);
|
||||
fs.mkdirSync(resourceDir, { recursive: true });
|
||||
writeWorkspaceManifest(root, "user_real", "task479");
|
||||
|
||||
// 主正文:README.md,引用一个已存在的附件 resource-note.md
|
||||
fs.writeFileSync(
|
||||
path.join(root, relativePath),
|
||||
[
|
||||
"---",
|
||||
"title: Lifecycle Smoke",
|
||||
"---",
|
||||
"",
|
||||
"# Lifecycle Smoke",
|
||||
"",
|
||||
"正文内容不变。",
|
||||
"",
|
||||
"[resource-note.md](" + resourceDirName + "/resource-note.md)",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(path.join(root, resourceDirName, "resource-note.md"), "# Resource Note\n\n已有资源正文\n", "utf8");
|
||||
|
||||
// 为 Check 6 准备一个带 H1 的文件(文件名不含空格,避免空格编码干扰测试)
|
||||
fs.writeFileSync(path.join(root, "MyNotes.md"), "# Body Heading\n\n正文内容\n", "utf8");
|
||||
|
||||
// 为 Check 5 准备足够的文件树条目来产生可滚动区域
|
||||
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
|
||||
for (let i = 0; i < 30; i++) {
|
||||
fs.writeFileSync(path.join(root, "docs", `doc-${String(i).padStart(2, "0")}.md`), `# Doc ${i}\n`, "utf8");
|
||||
}
|
||||
// 为 Check 5 准备的附件 PDF
|
||||
fs.writeFileSync(path.join(resourceDir, "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 report-a\n", "utf8"));
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: process.env.HEADFUL !== "1",
|
||||
executablePath: CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1360, height: 900 },
|
||||
extraHTTPHeaders: {
|
||||
"x-mnote-actor-id": "user_real",
|
||||
"x-mnote-actor-type": "user",
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const popups = [];
|
||||
page.on("popup", (popup) => popups.push(popup));
|
||||
|
||||
const screenshots = [];
|
||||
const checks = {};
|
||||
let overallOk = true;
|
||||
|
||||
try {
|
||||
// ── Login ──
|
||||
await quickLogin(page);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Check 1: 主编辑区上传 / drop 附件后,附件应进入当前 Markdown 页面的资源目录
|
||||
// (README/uploaded-one.md 不是 root 同级)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const checkId = "editor-upload-to-page-resource-dir";
|
||||
try {
|
||||
await page.goto(documentUrl(root, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// 通过主编辑区斜杠上传上传一个 MD 文件
|
||||
await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n");
|
||||
|
||||
// 等待后端 write_local_markdown_asset() 落盘
|
||||
const correctPath = path.join(resourceDir, "uploaded-one.md");
|
||||
const wrongPath = path.join(root, "uploaded-one.md");
|
||||
const landedInResourceDir = await waitForFileExists(correctPath, UI_TIMEOUT_MS);
|
||||
const landedInRoot = fs.existsSync(wrongPath);
|
||||
|
||||
assert(
|
||||
landedInResourceDir,
|
||||
`上传文件应落在 page resource directory (${correctPath})。`
|
||||
+ ` landedInRoot=${landedInRoot} correctPath=${correctPath}`,
|
||||
);
|
||||
|
||||
checks[checkId] = {
|
||||
ok: true,
|
||||
message: `上传文件落在资源目录 ${correctPath}`,
|
||||
landedInResourceDir,
|
||||
landedInRoot,
|
||||
};
|
||||
} catch (err) {
|
||||
overallOk = false;
|
||||
checks[checkId] = {
|
||||
ok: false,
|
||||
message: `主编辑区上传落盘目录检查失败: ${err.message}`,
|
||||
error: err.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Check 2: 文件树 folder row 外部 drop 到目标文件夹时,文件应进入该目标 folder
|
||||
// 而不是当前 Markdown 页面的资源目录
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const checkId = "filetree-folder-drop-to-target";
|
||||
try {
|
||||
await page.goto(documentUrl(root, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
// 等待文件树就绪
|
||||
const docsFolderRow = page.locator(
|
||||
'#sidebar-file-tree-root .tree-row[data-row-id="local:folder:docs"]',
|
||||
).first();
|
||||
await docsFolderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const droppedFileName = "drag-dropped-note.md";
|
||||
const droppedContent = "# Drag Dropped\n\n从外部拖入\n";
|
||||
|
||||
// 使用 dispatchEvent 模拟外部文件拖入 docs 文件夹
|
||||
await docsFolderRow.dispatchEvent("dragover", {
|
||||
dataTransfer: await page.evaluateHandle(
|
||||
({ fileName, fileContent }) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
|
||||
return dt;
|
||||
},
|
||||
{ fileName: droppedFileName, fileContent: droppedContent },
|
||||
),
|
||||
});
|
||||
await docsFolderRow.dispatchEvent("drop", {
|
||||
dataTransfer: await page.evaluateHandle(
|
||||
({ fileName, fileContent }) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
|
||||
return dt;
|
||||
},
|
||||
{ fileName: droppedFileName, fileContent: droppedContent },
|
||||
),
|
||||
});
|
||||
|
||||
// 等待文件落盘
|
||||
const droppedPath = path.join(root, "docs", droppedFileName);
|
||||
const exists = await waitForFileExists(droppedPath, UI_TIMEOUT_MS);
|
||||
assert(exists, `拖入 docs 文件夹后文件应出现在 ${droppedPath}`);
|
||||
|
||||
// 确保没有出现在 root 或 README/ 下
|
||||
const wrongPath = path.join(root, droppedFileName);
|
||||
const wrongResourcePath = path.join(resourceDir, droppedFileName);
|
||||
assert(
|
||||
!fs.existsSync(wrongPath) && !fs.existsSync(wrongResourcePath),
|
||||
`拖入 docs 文件夹的文件不应落在 root (${wrongPath}) 或 resourceDir (${wrongResourcePath}) 下`,
|
||||
);
|
||||
|
||||
checks[checkId] = {
|
||||
ok: true,
|
||||
message: `外部拖入文件进入目标文件夹 docs/`,
|
||||
droppedPath,
|
||||
exists,
|
||||
};
|
||||
} catch (err) {
|
||||
overallOk = false;
|
||||
checks[checkId] = {
|
||||
ok: false,
|
||||
message: `文件树 folder drop 检查失败: ${err.message}`,
|
||||
error: err.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Check 3: 删除真实附件文件后,刷新页面,正文中的 Markdown 链接仍应可见
|
||||
// 如果当前实现失败,先记录当前 failure,但断言应表达预期行为
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const checkId = "broken-link-after-real-file-delete";
|
||||
try {
|
||||
await page.goto(documentUrl(root, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// 先确认正文中有附件链接
|
||||
const linkTextBefore = await page.evaluate(() => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return editor instanceof HTMLElement ? editor.textContent || "" : "";
|
||||
});
|
||||
assert(
|
||||
linkTextBefore.includes("resource-note.md"),
|
||||
`初始正文应包含附件链接 resource-note.md,实际内容: ${linkTextBefore.slice(0, 500)}`,
|
||||
);
|
||||
|
||||
// 从磁盘删除真实附件文件
|
||||
const assetPath = path.join(resourceDir, "resource-note.md");
|
||||
assert(fs.existsSync(assetPath), "测试前置条件:附件文件应存在");
|
||||
fs.unlinkSync(assetPath);
|
||||
|
||||
// 刷新页面
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
// 给异步解析和渲染留时间
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 检查正文是否仍包含附件链接文本
|
||||
const linkTextAfter = await page.evaluate(() => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
return editor instanceof HTMLElement ? editor.textContent || "" : "";
|
||||
});
|
||||
|
||||
// 预期行为:链接应保留
|
||||
const linkPreserved = linkTextAfter.includes("resource-note.md");
|
||||
|
||||
checks[checkId] = {
|
||||
ok: linkPreserved,
|
||||
message: linkPreserved
|
||||
? "删除真实附件后刷新,正文链接仍保留"
|
||||
: "删除真实附件后刷新,正文链接消失(当前实现未保留 broken link)",
|
||||
assetPath,
|
||||
deleted: true,
|
||||
linkPreserved,
|
||||
editorText: linkTextAfter.slice(0, 500),
|
||||
};
|
||||
|
||||
if (!linkPreserved) {
|
||||
overallOk = false;
|
||||
}
|
||||
} catch (err) {
|
||||
overallOk = false;
|
||||
checks[checkId] = {
|
||||
ok: false,
|
||||
message: `删除真实附件后正文链接保留检查失败: ${err.message}`,
|
||||
error: err.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Check 4: 刚进入 local folder、未先打开任何 Markdown 文件时,
|
||||
// 点击 PDF / Markdown 附件 row 应在主编辑区 tab 打开,不应产生 popup。
|
||||
// 若当前 UI 无法直接进入这种状态,记录原因并把该 check 标记为 skipped
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const checkId = "direct-attach-open-without-active-md";
|
||||
try {
|
||||
// 使用独立 asset-only 工作区,确保 local folder 首屏没有 active Markdown document。
|
||||
const assetOnlyRoot = path.join(root, "asset-only-workspace");
|
||||
fs.mkdirSync(path.join(assetOnlyRoot, "attachments"), { recursive: true });
|
||||
writeWorkspaceManifest(assetOnlyRoot, "user_real", "task479-asset-only");
|
||||
fs.writeFileSync(path.join(assetOnlyRoot, "attachments", "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 asset-only report-a\n", "utf8"));
|
||||
|
||||
const homeUrl = localFolderHomeUrl(assetOnlyRoot);
|
||||
await page.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
// 等待文件树就绪
|
||||
const fileTreeRoot = page.locator("#sidebar-file-tree-root").first();
|
||||
await fileTreeRoot.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
|
||||
const hasResourceHost = await page.locator('[data-mnote-resource-tab-host][data-pane-role="primary"]')
|
||||
.first()
|
||||
.count()
|
||||
.then((count) => count > 0)
|
||||
.catch(() => false);
|
||||
|
||||
const resourceFolderRow = page
|
||||
.locator('#sidebar-file-tree-root .tree-row[data-node-id="local:node:attachments"], #sidebar-file-tree-root .tree-row[data-row-id="local:folder:attachments"]')
|
||||
.first();
|
||||
if (await resourceFolderRow.isVisible().catch(() => false)) {
|
||||
const expanded = await resourceFolderRow.getAttribute("aria-expanded").catch(() => null);
|
||||
const resourceFolderToggle = resourceFolderRow.locator('[data-testid="filetree-toggle"]').first();
|
||||
if (expanded !== "true" && await resourceFolderToggle.isVisible().catch(() => false)) {
|
||||
await resourceFolderToggle.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// 找到附件 row (data-testid="filetree-asset-row" 或 data-row-kind="asset"/"attachment")
|
||||
const pdfAssetRow = page.locator('[data-testid="filetree-asset-row"]', {
|
||||
hasText: "report-a.pdf",
|
||||
}).first();
|
||||
await pdfAssetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
const hasPdfRow = await pdfAssetRow.isVisible().catch(() => false);
|
||||
|
||||
assert(hasResourceHost, "无 active Markdown document 时应渲染主编辑区 resource tab host");
|
||||
assert(hasPdfRow, "无 active Markdown document 时应渲染 report-a.pdf 附件 row");
|
||||
|
||||
const popupCountBefore = popups.length;
|
||||
await pdfAssetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const popupCountAfter = popups.length;
|
||||
const resourceTabVisible = await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])')
|
||||
.isVisible().catch(() => false);
|
||||
const resourceTabTitle = await page
|
||||
.locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "report-a.pdf" })
|
||||
.first()
|
||||
.textContent({ timeout: UI_TIMEOUT_MS })
|
||||
.catch(() => "");
|
||||
|
||||
assert(
|
||||
popupCountAfter === popupCountBefore,
|
||||
`点击附件 row 不应产生新浏览器窗口/标签页,popup 数:${popupCountBefore} → ${popupCountAfter}`,
|
||||
);
|
||||
assert(resourceTabVisible, "点击附件 row 后应显示主编辑区 resource tab host");
|
||||
assert((resourceTabTitle || "").includes("report-a.pdf"), `资源 tab 标题应包含 report-a.pdf,实际: ${resourceTabTitle}`);
|
||||
|
||||
checks[checkId] = {
|
||||
ok: true,
|
||||
message: "无 active Markdown document 时,点击附件 row 后主编辑区 resource tab 打开且不产生 popup",
|
||||
popupCount: popupCountAfter - popupCountBefore,
|
||||
resourceTabVisible,
|
||||
resourceTabTitle,
|
||||
};
|
||||
} catch (err) {
|
||||
overallOk = false;
|
||||
checks[checkId] = {
|
||||
ok: false,
|
||||
message: `直接打开附件检查异常: ${err.message}`,
|
||||
error: err.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Check 5: 文件树滚动到底和 resize handle 上滚轮后,
|
||||
// document.scrollingElement.scrollTop 应保持 0 或不产生底部空白
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const checkId = "filetree-scroll-no-body-scroll";
|
||||
try {
|
||||
await page.goto(documentUrl(root, relativePath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator("#sidebar-file-tree-root").waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
// 先让文件树出现
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 获取滚动前的 body scrollTop
|
||||
const scrollBefore = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
|
||||
|
||||
// 尝试让文件树滚动到底:在文件树上用鼠标滚轮
|
||||
const fileTree = page.locator("#sidebar-file-tree-root").first();
|
||||
await fileTree.hover({ timeout: UI_TIMEOUT_MS });
|
||||
|
||||
// 多次在文件树上滚轮模拟快速滚到底
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await page.mouse.wheel(0, 120);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
|
||||
// 再在文件树区域一次性大量滚轮
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await page.mouse.wheel(0, 300);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const scrollAfter = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
|
||||
|
||||
// 预期:body scrollTop 不应被文件树滚轮事件改变
|
||||
const bodyNotScrolled = scrollAfter <= scrollBefore;
|
||||
|
||||
checks[checkId] = {
|
||||
ok: bodyNotScrolled,
|
||||
message: bodyNotScrolled
|
||||
? "文件树内滚轮没有导致 body 滚动"
|
||||
: "文件树滚轮导致 body 产生了滚动(出现底部空白)",
|
||||
scrollBefore,
|
||||
scrollAfter,
|
||||
delta: scrollAfter - scrollBefore,
|
||||
};
|
||||
|
||||
if (!bodyNotScrolled) {
|
||||
overallOk = false;
|
||||
}
|
||||
} catch (err) {
|
||||
overallOk = false;
|
||||
checks[checkId] = {
|
||||
ok: false,
|
||||
message: `文件树滚动 body 空白检查失败: ${err.message}`,
|
||||
error: err.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Check 6: 本地 Markdown 标题:File Name.md 且正文第一行 # Body Heading 时,
|
||||
// File Tree 显示 "File Name.md",正文仍显示 "Body Heading"
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const checkId = "markdown-title-from-filename-not-h1";
|
||||
try {
|
||||
const fileName = "MyNotes.md";
|
||||
const fileRelPath = fileName;
|
||||
// 确认文件已被写入
|
||||
assert(fs.existsSync(path.join(root, fileName)), "File Name.md 应存在");
|
||||
|
||||
// 导航到该文件
|
||||
await page.goto(documentUrl(root, fileRelPath), {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
// 等待文件树就绪
|
||||
await page.locator("#sidebar-file-tree-root").waitFor({
|
||||
state: "visible",
|
||||
timeout: UI_TIMEOUT_MS,
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 从文件树获知 MyNotes.md 的标题
|
||||
const treeTitle = await page.evaluate(() => {
|
||||
const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-row-id^="local:markdown:"]');
|
||||
for (const row of rows) {
|
||||
const text = row.querySelector(".tree-link-title")?.textContent?.trim() || row.textContent?.trim() || "";
|
||||
if (text.includes("MyNotes")) return text;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// 获取正文第一行 / 标题
|
||||
const bodyHeading = await page.evaluate(() => {
|
||||
const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
|
||||
if (!(editor instanceof HTMLElement)) return null;
|
||||
// 尝试获取第一个 h1
|
||||
const h1 = editor.querySelector("h1");
|
||||
if (h1 instanceof HTMLElement) return h1.textContent?.trim() || "";
|
||||
// fallback: textContent 第一行
|
||||
return (editor.textContent || "").split("\n").map((l) => l.trim()).filter(Boolean)[0] || "";
|
||||
});
|
||||
|
||||
// 预期:文件树显示 MyNotes.md(文件名),正文显示 Body Heading(H1 内容)
|
||||
const treeShowsFileName = treeTitle && treeTitle.includes("MyNotes.md");
|
||||
const bodyShowsHeading = bodyHeading && bodyHeading.includes("Body Heading");
|
||||
|
||||
checks[checkId] = {
|
||||
ok: !!(treeShowsFileName && bodyShowsHeading),
|
||||
message: `treeTitle="${treeTitle}" bodyHeading="${bodyHeading}"`,
|
||||
treeTitle,
|
||||
bodyHeading,
|
||||
treeShowsFileName,
|
||||
bodyShowsHeading,
|
||||
};
|
||||
} catch (err) {
|
||||
overallOk = false;
|
||||
checks[checkId] = {
|
||||
ok: false,
|
||||
message: `标题来源检查失败: ${err.message}`,
|
||||
error: err.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── 截图 ──
|
||||
{
|
||||
const shotTimestamp = Date.now();
|
||||
const shotDir = path.join(root, `screenshots-${shotTimestamp}`);
|
||||
fs.mkdirSync(shotDir, { recursive: true });
|
||||
await page.screenshot({ path: path.join(shotDir, "final-state.png"), fullPage: false });
|
||||
screenshots.push(path.join(shotDir, "final-state.png"));
|
||||
}
|
||||
|
||||
// ── 结果 ──
|
||||
const result = {
|
||||
ok: overallOk,
|
||||
root,
|
||||
checks,
|
||||
screenshots,
|
||||
popups: popups.length,
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} finally {
|
||||
await context.close().catch(() => undefined);
|
||||
await browser.close().catch(() => undefined);
|
||||
// 清理临时目录,保留 result JSON 被 console.log 输出即可
|
||||
// 如果用户需要查看留下的文件,可设置 KEEP_TEMP=1
|
||||
if (!process.env.KEEP_TEMP) {
|
||||
try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user