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:
parent
77a22bbfb1
commit
485d4b1cc4
18
input.css
18
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 {
|
||||
|
||||
@ -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: ®ex::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");
|
||||
|
||||
@ -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 {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user