fix(markdown): 修复移动端表格无法横向滚动

宽表格在移动端被外层 <main> 的 overflow-hidden 直接裁掉,既看不到也滚不到。
根因是三层叠加:裸 table 无滚动容器、外层裁剪、th 强制 nowrap 加剧超宽。

- markdown.rs: 新增 wrap_tables,给每个 table 套 <div class=table-wrap>
  滚动容器(仿 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 包裹。
This commit is contained in:
xfy 2026-07-23 16:07:11 +08:00
parent 77a22bbfb1
commit 485d4b1cc4
3 changed files with 133 additions and 1 deletions

View File

@ -583,9 +583,17 @@
border: 0;
}
/* table-wrap渲染器给宽表格套的滚动容器移动端窄屏可横向滚动
src/api/markdown.rs::wrap_tablesmin-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 {

View File

@ -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")]
/// 把每个 `<table>...</table>` 包进可横向滚动的 `<div class="table-wrap">`。
///
/// 移动端窄屏下宽表格无法横向滚动pulldown-cmark 产出裸 table外层 `<main>` 又是
/// `overflow-hidden` 会把超宽内容直接裁掉。这里给 table 套一层 `table-wrap`CSS 配
/// `overflow-x: auto`),让表格在容器内滚动而非撑破页面。
///
/// 正则匹配 pulldown-cmark 输出的 `<table ...>...</table>`(非贪婪、跨行),包裹整个
/// table 标签。对已包裹的 HTML 幂等(不会二次嵌套)——`table-wrap` div 内的 table
/// 仍会被正则命中并再次包裹,故调用方仅在渲染管线调用一次。
fn wrap_tables(html: &str) -> String {
use regex::Regex;
use std::sync::LazyLock;
static TABLE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)<table(\s[^>]*)?>.*?</table>").unwrap());
TABLE_RE
.replace_all(html, |caps: &regex::Captures| {
format!("<div class=\"table-wrap\">{}</div>", &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 = "<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>";
let result = wrap_tables(html);
assert!(
result.starts_with("<div class=\"table-wrap\"><table>")
&& result.ends_with("</table></div>"),
"应整体包裹一层 table-wrap, got: {}",
result
);
}
#[test]
fn wrap_tables_wraps_table_with_attributes() {
// pulldown-cmark 不会给 table 加属性,但正则需兼容带属性/自闭合起始的 table。
let html = r#"<table class="x"><tr><td>1</td></tr></table>"#;
let result = wrap_tables(html);
assert!(
result.contains("<div class=\"table-wrap\"><table"),
"应从 table 起始标签整体包裹, got: {}",
result
);
assert!(result.ends_with("</table></div>"));
}
#[test]
fn wrap_tables_wraps_multiple_tables() {
let html = "<table><tr><td>1</td></tr></table>\n<p>间隔</p>\n<table><tr><td>2</td></tr></table>";
let result = wrap_tables(html);
let wrap_count = result.matches("<div class=\"table-wrap\">").count();
assert_eq!(wrap_count, 2, "两个 table 应各自包裹, got: {}", result);
// 中间段落不被误包
assert!(result.contains("\n<p>间隔</p>\n"));
}
#[test]
fn wrap_tables_handles_multiline_table() {
// pulldown-cmark 产出的 table HTML 是单行无换行的,但正则用 (?s) 跨行兼容手写 HTML。
let html = "<table>\n <tr>\n <td>1</td>\n </tr>\n</table>";
let result = wrap_tables(html);
assert!(
result.starts_with("<div class=\"table-wrap\"><table>")
&& result.ends_with("</table></div>"),
"跨行 table 应整体包裹, got: {}",
result
);
}
#[test]
fn wrap_tables_does_not_touch_non_table_html() {
let html = "<p>段落</p><ul><li>项</li></ul>";
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 = "<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>";
let wrapped = wrap_tables(html);
let cleaned = clean_html(&wrapped);
assert!(
cleaned.contains(r#"<div class="table-wrap">"#),
"clean_html 应保留 table-wrap div, got: {}",
cleaned
);
assert!(
cleaned.contains("<table>") && cleaned.contains("</table>"),
"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#"<div class="table-wrap">"#),
"markdown table 应被 table-wrap 包裹, got: {}",
result.html
);
assert!(result.html.contains("<table>"));
}
#[test]
fn slugify_heading_simple() {
assert_eq!(slugify_heading("Hello World"), "hello-world");

View File

@ -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::<Route> {} }
}
Footer {}