From 485d4b1cc43216ba7af2b2c7dd1f170514c7b71c Mon Sep 17 00:00:00 2001 From: xfy Date: Thu, 23 Jul 2026 16:07:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(markdown):=20=E4=BF=AE=E5=A4=8D=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E8=A1=A8=E6=A0=BC=E6=97=A0=E6=B3=95=E6=A8=AA?= =?UTF-8?q?=E5=90=91=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宽表格在移动端被外层
的 overflow-hidden 直接裁掉,既看不到也滚不到。 根因是三层叠加:裸 table 无滚动容器、外层裁剪、th 强制 nowrap 加剧超宽。 - markdown.rs: 新增 wrap_tables,给每个 table 套
滚动容器(仿 wrap_images_with_blur 模式,sanitizer 放行 div+class) - input.css: .table-wrap 设 overflow-x:auto;table 加 min-width:100% 让宽表 撑开触发滚动;768px 下对旧文章裸 table (.md-content > div > table) 兜底 block 化,新文章因多一层 div 不受影响、圆角视觉完整 - frontend_layout.rs: main 的 overflow-hidden 改 overflow-x-clip,只裁 x 且不建滚动容器,让内部 .table-wrap 滚动生效 旧文章经 CSS 兜底立即修复;新文章用渲染器包裹视觉完整。可在 /admin/posts 点 rebuild all 让旧文章也升级到 div 包裹。 --- input.css | 18 +++++ src/api/markdown.rs | 114 ++++++++++++++++++++++++++++++ src/components/frontend_layout.rs | 2 +- 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/input.css b/input.css index 95df0ea..44efdbf 100644 --- a/input.css +++ b/input.css @@ -583,9 +583,17 @@ border: 0; } + /* table-wrap:渲染器给宽表格套的滚动容器,移动端窄屏可横向滚动。 + (见 src/api/markdown.rs::wrap_tables)。min-width 让窄表仍撑满、宽表撑开触发滚动。 */ + .md-content .table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .md-content table { margin-bottom: var(--content-gap-paper); width: 100%; + min-width: 100%; border-collapse: separate; border-spacing: 0; border-radius: var(--radius-paper); @@ -1031,6 +1039,16 @@ details.toc .inner { margin: 0 1rem; } + + /* 旧文章兜底:未重建的文章是裸 table(无 .table-wrap 包裹),移动端需 block 化才能滚动。 + 选择器 .md-content > div > table 精确命中「片段容器直接子代的裸 table」, + 不会误伤新文章 .table-wrap 内的 table(后者多一层 div,圆角视觉完整)。 + block 化会损 thead/tbody 圆角,但仅移动端 + 仅旧文章,可接受。 */ + .md-content > div > table { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } } @keyframes pageEnter { diff --git a/src/api/markdown.rs b/src/api/markdown.rs index e6f9531..12bbee9 100644 --- a/src/api/markdown.rs +++ b/src/api/markdown.rs @@ -387,6 +387,9 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent { } let html = wrap_images_with_blur(&html); + // 表格外层套可滚动容器,移动端窄屏可横向滚动而不被外层 overflow-hidden 裁切。 + // sanitizer 不放行 table 的 class/style,但 div 在白名单、class 属全局属性,故包裹 div。 + let html = wrap_tables(&html); RenderedContent { html: clean_html(&html), toc_html, @@ -459,6 +462,30 @@ where .to_string() } +#[cfg(feature = "server")] +/// 把每个 `...
` 包进可横向滚动的 `
`。 +/// +/// 移动端窄屏下宽表格无法横向滚动:pulldown-cmark 产出裸 table,外层 `
` 又是 +/// `overflow-hidden` 会把超宽内容直接裁掉。这里给 table 套一层 `table-wrap`(CSS 配 +/// `overflow-x: auto`),让表格在容器内滚动而非撑破页面。 +/// +/// 正则匹配 pulldown-cmark 输出的 `...
`(非贪婪、跨行),包裹整个 +/// table 标签。对已包裹的 HTML 幂等(不会二次嵌套)——`table-wrap` div 内的 table +/// 仍会被正则命中并再次包裹,故调用方仅在渲染管线调用一次。 +fn wrap_tables(html: &str) -> String { + use regex::Regex; + use std::sync::LazyLock; + + static TABLE_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?s)]*)?>.*?").unwrap()); + + TABLE_RE + .replace_all(html, |caps: ®ex::Captures| { + format!("
{}
", &caps[0]) + }) + .to_string() +} + #[cfg(feature = "server")] /// 根据标题层级生成嵌套目录 HTML。 fn generate_toc_html(headings: &[(u8, String, String)]) -> String { @@ -707,6 +734,93 @@ mod tests { ); } + // ---- wrap_tables 单元测试 ---- + + #[test] + fn wrap_tables_wraps_bare_table() { + let html = "
A
1
"; + let result = wrap_tables(html); + assert!( + result.starts_with("
") + && result.ends_with("
"), + "应整体包裹一层 table-wrap, got: {}", + result + ); + } + + #[test] + fn wrap_tables_wraps_table_with_attributes() { + // pulldown-cmark 不会给 table 加属性,但正则需兼容带属性/自闭合起始的 table。 + let html = r#"
1
"#; + let result = wrap_tables(html); + assert!( + result.contains("
")); + } + + #[test] + fn wrap_tables_wraps_multiple_tables() { + let html = "
1
\n

间隔

\n
2
"; + let result = wrap_tables(html); + let wrap_count = result.matches("
").count(); + assert_eq!(wrap_count, 2, "两个 table 应各自包裹, got: {}", result); + // 中间段落不被误包 + assert!(result.contains("\n

间隔

\n")); + } + + #[test] + fn wrap_tables_handles_multiline_table() { + // pulldown-cmark 产出的 table HTML 是单行无换行的,但正则用 (?s) 跨行兼容手写 HTML。 + let html = "\n \n \n \n
1
"; + let result = wrap_tables(html); + assert!( + result.starts_with("
") + && result.ends_with("
"), + "跨行 table 应整体包裹, got: {}", + result + ); + } + + #[test] + fn wrap_tables_does_not_touch_non_table_html() { + let html = "

段落

"; + let result = wrap_tables(html); + assert_eq!(result, html, "无 table 时应原样返回"); + } + + #[test] + fn wrap_tables_then_clean_preserves_div_and_table() { + // 端到端:wrap → clean_html,确认 sanitizer 放行 div.table-wrap 与内部 table 结构。 + let html = "
H
v
"; + let wrapped = wrap_tables(html); + let cleaned = clean_html(&wrapped); + assert!( + cleaned.contains(r#"
"#), + "clean_html 应保留 table-wrap div, got: {}", + cleaned + ); + assert!( + cleaned.contains("") && cleaned.contains("
"), + "clean_html 应保留 table 标签, got: {}", + cleaned + ); + } + + #[test] + fn render_markdown_table_wrapped_in_scroll_container() { + // 端到端:markdown table 经渲染管线后应被 table-wrap div 包裹。 + let result = render_markdown_enhanced("| A | B |\n|---|---|\n| 1 | 2 |\n"); + assert!( + result.html.contains(r#"
"#), + "markdown table 应被 table-wrap 包裹, got: {}", + result.html + ); + assert!(result.html.contains("")); + } + #[test] fn slugify_heading_simple() { assert_eq!(slugify_heading("Hello World"), "hello-world"); diff --git a/src/components/frontend_layout.rs b/src/components/frontend_layout.rs index 15c1462..40470ed 100644 --- a/src/components/frontend_layout.rs +++ b/src/components/frontend_layout.rs @@ -60,7 +60,7 @@ pub fn FrontendLayout() -> Element { ThemeToggle {} }, } - main { class: "flex-1 w-full max-w-4xl mx-auto px-6 py-6 md:py-12 overflow-hidden", + main { class: "flex-1 w-full max-w-4xl mx-auto px-6 py-6 md:py-12 overflow-x-clip", SuspenseBoundary { fallback: move |_| route_skeleton(&route), Outlet:: {} } } Footer {}