diff --git a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md index c94e162d..1769548b 100644 --- a/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md +++ b/bugs/04-tree-domain/process/4-49-filetree-drag-upload-target-parent-folder-v1.md @@ -299,7 +299,7 @@ PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folde 已修复 / 已验证: - 2.1 主编辑区上传附件落盘到当前 Markdown 页面资源目录:`task479` 通过。 -- 2.2 删除真实附件后刷新,正文 broken link 保留:`task479` 通过。 +- 2.2 删除真实附件后刷新,正文 broken link 保留;点击缺失附件进入主编辑区 resource tab error state,不弹新窗口:`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` 通过。 @@ -322,4 +322,5 @@ PLAYWRIGHT_CHROME_EXECUTABLE=/snap/bin/chromium node scripts/task479-local-folde - 2.3 Backspace / Delete 删除相邻附件已补浏览器级 smoke:`task479` Check 7 通过。Backspace 与手柄删除都只删除 Markdown 链接,不删除真实附件文件,相邻附件刷新后仍保持可点击附件块。 - 2.4 切回“我的空间”已按当前 local-first MVP 语义修复并复测:旧“云空间”入口不再进入 `convex_workspace`,改为“我的空间”,通过 `mnoteHome=1` 回默认 `my-space`,避免最近普通本地目录和旧 Convex compat 接管;`task441-local-folder-cloud-switch-smoke.js` 通过。 - 2.5 / 2.7 resource tab 打开目标已补 active/selected row 验证:`task479` Check 4 断言刚进入 asset-only local folder 点击 `report-a.pdf` 后,主编辑区 resource tab 打开且 File Tree 唯一 selected/active row 保持在该附件 row。 -- 页面设置中“隐藏本地 Markdown 文件标题”的可切换 UI / 持久化尚未实现;当前只落地默认隐藏。 +- 2.2 missing tab 点击根因已收口:本地 Markdown 附件缺失时,`openTiptapResourceTab()` 读取 `/api/local-folder/resource/read` 失败,`openResourceInActiveTab()` 已创建 resource tab 并调用 `markResourceTabError()`,但此前仍返回 `false`;外层 `openEditorAttachmentDetail()` 因 `opened=false` 执行 `window.open()` fallback,导致弹出新窗口。修复为错误 tab 已渲染时返回 `true`,表示主编辑区已接管打开意图。 +- 2.8 页面设置中“隐藏本地 Markdown 文件标题”的可切换 UI / 持久化已实现:新增 `hideTitleHeader` page option,本地 Markdown 默认开启;关闭后页头立即显示,刷新后从 `.mnote/page-options.json` 恢复。`task479` Check 6 通过。 diff --git a/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md b/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md index 0d592836..46b1eea4 100644 --- a/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md +++ b/design/04-tree-domain/process/4-48-local-folder-markdown-resource-lifecycle-contract-v1.md @@ -119,9 +119,9 @@ Markdown 正文链接只是引用,不是所有权声明。 - [x] 主编辑区附件删除 command 只删除 Markdown 链接并保存。 - [x] 手柄删除复用同一 command,不做纯 DOM 隐藏。 -- [ ] Parser / serializer 保留 broken link。 +- [x] Parser / serializer 保留 broken link。 - [ ] watcher 收到真实文件删除后刷新存在性状态,不改正文。 -- [ ] 缺失资源点击进入 missing tab。 +- [x] 缺失资源点击进入 missing / error tab。 ### 5.4 Resource tab host @@ -137,8 +137,8 @@ Markdown 正文链接只是引用,不是所有权声明。 - [x] File Tree / Page Tree 标题默认来自文件名。 - [x] 正文 H1 保留在正文,不参与标题推导。 - [x] 本地 Markdown 页头标题默认隐藏。 -- [ ] 页面设置新增“隐藏本地 Markdown 文件标题”的可切换 UI。 -- [ ] 设置刷新后持久生效。 +- [x] 页面设置新增“隐藏本地 Markdown 文件标题”的可切换 UI。 +- [x] 设置刷新后持久生效。 - [ ] 重命名标题时走文件重命名和 bundle 目录同步。 ### 5.6 滚动边界 @@ -146,7 +146,7 @@ Markdown 正文链接只是引用,不是所有权声明。 - [ ] Workbench 根容器固定视口高度。 - [ ] Sidebar / File Tree 是唯一纵向滚动容器。 - [ ] resize handle wheel 不穿透到 body。 -- [ ] smoke 断言 `document.scrollingElement.scrollTop` 不变化。 +- [x] smoke 断言 `document.scrollingElement.scrollTop` 不变化。 ## 6. 建议切片 @@ -188,3 +188,5 @@ codegraph sync . - 2026-05-24 追加:来源菜单的旧“云空间”入口按当前 local-first MVP 语义改为“我的空间”,默认回到受管 `my-space` 本地根;新增 `mnoteHome=1` 防止被最近普通本地目录自动重定向接管。`task441-local-folder-cloud-switch-smoke.js` 已按当前语义通过。 - 2026-05-24 追加:上传链路已改为显式 `uploadIntent` 合同。主编辑区上传发送并返回 `editor.markdown.attach`,写入 page resource directory;File Tree folder drop 发送并返回 `filetree.folder.drop`,写入 `targetRelativePath` 指向目录。`task479` 已断言响应中的 `uploadIntent`、`rootRelativePath`、`markdownRelativePath`、`ownerDocumentId`。 - 2026-05-24 追加:`task479` Check 4 已补充断言 resource tab 打开后 File Tree 的 `data-selected` / `data-active` 唯一保持在被打开附件 row,避免资源打开后焦点跳到其他 row。 +- 2026-05-24 追加:`task479` Check 3 已补齐缺失真实附件后的点击行为断言。根因是 resource tab 已创建并标记 error 后仍返回 `false`,外层附件点击逻辑因此执行 `window.open()` 兜底;修复后错误 tab 也视为主编辑区已接管,broken link 刷新保留、点击不弹新窗口,并显示 `resource-note.md` 的 resource tab error state。 +- 2026-05-24 追加:页面设置新增 `hideTitleHeader`,本地 Markdown 默认开启隐藏页头;用户可在页面设置中关闭,写入 `.mnote/page-options.json`,刷新后由 Page Aggregate 恢复。`task479` Check 6 已断言默认勾选、切换后页头立即显示、刷新后保持显示。 diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index eb1e0132..8c211882 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -5766,6 +5766,9 @@ fn build_page_aggregate_projection_result( show_block_ref_count: bool_field(meta, "show_block_ref_count") .or_else(|| bool_field(meta, "showBlockRefCount")) .unwrap_or(false), + hide_title_header: bool_field(meta, "hide_title_header") + .or_else(|| bool_field(meta, "hideTitleHeader")) + .unwrap_or(false), embed_default_block_id: meta .get("embed_default_block_id") .or_else(|| meta.get("embedDefaultBlockId")) diff --git a/rust/crates/core-protocol/src/page_aggregate.rs b/rust/crates/core-protocol/src/page_aggregate.rs index e7409e6c..bbb60190 100644 --- a/rust/crates/core-protocol/src/page_aggregate.rs +++ b/rust/crates/core-protocol/src/page_aggregate.rs @@ -93,6 +93,7 @@ pub struct PageOptions { pub page_font: String, pub hide_child_pages: bool, pub show_block_ref_count: bool, + pub hide_title_header: bool, pub embed_default_block_id: Value, } @@ -111,6 +112,7 @@ impl Default for PageOptions { page_font: "default".into(), hide_child_pages: false, show_block_ref_count: false, + hide_title_header: false, embed_default_block_id: Value::Null, } } diff --git a/rust/crates/mnote-web/src/page_aggregate/builder.rs b/rust/crates/mnote-web/src/page_aggregate/builder.rs index 54b61d83..2c244df4 100644 --- a/rust/crates/mnote-web/src/page_aggregate/builder.rs +++ b/rust/crates/mnote-web/src/page_aggregate/builder.rs @@ -46,6 +46,7 @@ pub struct PageAggregateBuilder { page_font: String, hide_child_pages: bool, show_block_ref_count: bool, + hide_title_header: bool, embed_default_block_id: Value, content: Value, revision: Value, @@ -86,6 +87,7 @@ impl PageAggregateBuilder { page_font: "default".to_string(), hide_child_pages: false, show_block_ref_count: false, + hide_title_header: false, embed_default_block_id: Value::Null, content: Value::Null, revision: Value::Null, @@ -305,6 +307,7 @@ impl PageAggregateBuilder { page_font: self.page_font, hide_child_pages: self.hide_child_pages, show_block_ref_count: self.show_block_ref_count, + hide_title_header: self.hide_title_header, embed_default_block_id: self.embed_default_block_id, }; let layout_options = to_value(&page_options).expect("PageOptions 序列化不应失败"); diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index 14d02b17..655e5a84 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -1597,7 +1597,8 @@ mod tests { "rootUri": root_uri, "options": { "wideLayout": true, - "showToc": false + "showToc": false, + "hideTitleHeader": false } }) .to_string(), @@ -1625,6 +1626,10 @@ mod tests { options_json["pages"][&renamed_document_id]["showToc"], false ); + assert_eq!( + options_json["pages"][&renamed_document_id]["hideTitleHeader"], + false + ); let _ = std::fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index df1ca312..27f26e5e 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -2769,12 +2769,19 @@ pub fn resolve_local_markdown_page_aggregate( let block_count = content.as_array().map(|blocks| blocks.len()).unwrap_or(0) as u64; let page_subtree = markdown_page_subtree(document_id, &title, &content); let workspace_id = local_workspace_id(&canonical_root); - let page_options = metadata + let page_options_value = metadata .page_options .get(document_id) - .or_else(|| metadata.page_options.get(&markdown_file.relative_path)) + .or_else(|| metadata.page_options.get(&markdown_file.relative_path)); + let mut page_options = page_options_value .map(page_options_from_metadata) .unwrap_or_default(); + if page_options_value + .and_then(page_options_hide_title_header_value) + .is_none() + { + page_options.hide_title_header = true; + } let character_count = parsed.body.chars().count() as u64; let word_count = parsed .body @@ -6696,9 +6703,19 @@ fn page_options_from_metadata(value: &Value) -> PageOptions { { options.protect_editing = protect_editing; } + if let Some(hide_title_header) = page_options_hide_title_header_value(value) { + options.hide_title_header = hide_title_header; + } options } +fn page_options_hide_title_header_value(value: &Value) -> Option { + value + .get("hideTitleHeader") + .or_else(|| value.get("hide_title_header")) + .and_then(Value::as_bool) +} + pub(crate) fn local_workspace_id(root: &Path) -> String { if let Ok(manifest) = load_local_workspace_manifest(root) { if !manifest.workspace_id.trim().is_empty() { @@ -8294,7 +8311,7 @@ mod tests { std::fs::write(root.join("README.md"), "# Local Options\n").expect("write md"); std::fs::write( root.join(".mnote").join("page-options.json"), - r#"{"version":1,"pages":{"local-md:README.md":{"wideLayout":true,"showToc":true,"showHeadingNumbers":true}}}"#, + r#"{"version":1,"pages":{"local-md:README.md":{"wideLayout":true,"showToc":true,"showHeadingNumbers":true,"hideTitleHeader":false}}}"#, ) .expect("write options"); let root_uri = format!("file://{}", root.display()); @@ -8304,6 +8321,7 @@ mod tests { assert!(aggregate.layout.page_options.wide_layout); assert!(aggregate.layout.page_options.show_toc); assert!(aggregate.layout.page_options.show_heading_numbers); + assert!(!aggregate.layout.page_options.hide_title_header); let _ = std::fs::remove_dir_all(&root); } @@ -10236,6 +10254,7 @@ fn main() {} let aggregate = resolve_local_markdown_page_aggregate(&root_uri, "local-md:File~20Name.md") .expect("aggregate"); assert_eq!(aggregate.title, "File Name"); + assert!(aggregate.layout.page_options.hide_title_header); let _ = std::fs::remove_dir_all(&root); } diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index 2db2dcaf..7648ab81 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -222,8 +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")} + primary_hide_title_header={aggregate.layout.page_options.hide_title_header} + secondary_hide_title_header={secondary_aggregate.as_ref().map(|aggregate| aggregate.layout.page_options.hide_title_header).unwrap_or(secondary_source_kind == Some("local_folder"))} show_admin_access_policy={is_local_access_policy_admin_context(&context)} /> }); @@ -4185,7 +4185,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { } catch (error) { console.warn('mnote resource tab 打开失败', error); markResourceTabError(entry); - return false; + return true; } }; diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index ffb73cc1..420d093c 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -501,6 +501,7 @@ const SIDEBAR_TREE_JS: &str = r##" pageFont: 'default', hideChildPages: false, showBlockRefCount: false, + hideTitleHeader: false, embedDefaultBlockId: null }; } @@ -545,12 +546,14 @@ const SIDEBAR_TREE_JS: &str = r##" || name === 'smallText' || name === 'layoutDensity' || name === 'pageFont' - || name === 'showHeadingNumbers'; + || name === 'showHeadingNumbers' + || (name === 'hideTitleHeader' && currentSourceKind() === 'local_folder'); } function pageOptionDescription(name) { if (name === 'wideLayout') return '自适应宽度'; if (name === 'smallText') return '小字体'; + if (name === 'hideTitleHeader') return '隐藏本地 Markdown 文件标题'; if (name === 'showToc') return '标题目录'; if (name === 'showHeadingNumbers') return '标题自动编号'; if (name === 'protectEditing') return '编辑保护'; @@ -564,6 +567,7 @@ const SIDEBAR_TREE_JS: &str = r##" if (name === 'wideLayout') return '已接通:主内容列宽度立即变化'; if (name === 'smallText') return '已接通:正文排版会更紧凑'; if (name === 'showHeadingNumbers') return '已接通:标题前显示顺序编号'; + if (name === 'hideTitleHeader') return '已接通:本地 Markdown 页头标题立即隐藏或显示'; if (name === 'layoutDensity') return '已接通:段落与列表间距会变化'; if (name === 'pageFont') return '已接通:当前页面字体会切换'; if (name === 'showToc') return '待接线:当前 Rust 壳还没有正式目录面板'; @@ -663,6 +667,13 @@ const SIDEBAR_TREE_JS: &str = r##" document.documentElement.setAttribute('data-page-font', String(options.pageFont || 'default')); document.documentElement.setAttribute('data-global-show-heading-numbers', String(globalShowHeadingNumbers)); document.documentElement.setAttribute('data-page-show-heading-numbers', String(showHeadingNumbers)); + document.documentElement.setAttribute('data-page-hide-title-header', String(Boolean(options.hideTitleHeader))); + var titleHeader = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header') || document.querySelector('.document-shell-header'); + if (titleHeader instanceof HTMLElement) { + var hidden = Boolean(options.hideTitleHeader); + titleHeader.hidden = hidden; + titleHeader.setAttribute('data-page-title-hidden', String(hidden)); + } } function normalizeSidebarTreeMode(value) { @@ -8358,6 +8369,7 @@ const SIDEBAR_TREE_JS: &str = r##" createPageOptionRow('smallText', 'checkbox') + createPageOptionRow('showToc', 'checkbox') + createPageOptionRow('protectEditing', 'checkbox') + + createPageOptionRow('hideTitleHeader', 'checkbox') + '' + '