Add anchor-based TOC navigation: parse #fragment anchors, find exact page within section

This commit is contained in:
2026-05-22 17:56:48 +08:00
parent 21e9aba274
commit 1d2407098c
3 changed files with 109 additions and 12 deletions

View File

@@ -11,6 +11,7 @@ fn parse_blocks(raw_text: &str) -> Vec<ContentBlock> {
continue;
}
let mut heading_level = 0u8;
let mut anchor: Option<String> = None;
let mut text = trimmed.to_string();
if text.contains('\x01') {
if let Some(pos) = text.find('\x01') {
@@ -20,19 +21,28 @@ fn parse_blocks(raw_text: &str) -> Vec<ContentBlock> {
.next()
.and_then(|c| c.to_digit(10))
.unwrap_or(1) as u8;
let after_level = &text[pos + 2..];
if let Some(anchor_start) = after_level.find('\x03') {
if let Some(anchor_end) = after_level.find('\x04') {
let anchor_str = &after_level[anchor_start + 1..anchor_end];
anchor = Some(anchor_str.to_string());
}
}
text.drain(pos..pos + 2);
}
text = text.replace(&['\x03', '\x04'][..], "");
text = text.replace('\x02', "");
}
let text = text.trim().to_string();
if !text.is_empty() {
blocks.push(ContentBlock { text, heading_level });
blocks.push(ContentBlock { text, heading_level, anchor });
}
}
if blocks.is_empty() {
blocks.push(ContentBlock {
text: String::new(),
heading_level: 0,
anchor: None,
});
}
blocks
@@ -201,6 +211,17 @@ pub fn find_page_by_snippet(section: &crate::book::Section, snippet: &str) -> Op
None
}
pub fn find_page_for_anchor(section: &crate::book::Section, anchor: &str) -> Option<usize> {
for (block_idx, block) in section.blocks.iter().enumerate() {
if let Some(ref a) = block.anchor {
if a == anchor {
return find_page_for_block(section, block_idx);
}
}
}
None
}
pub struct ReaderAction {
pub go_back: bool,
pub toggle_theme: bool,
@@ -212,6 +233,7 @@ pub struct ReaderAction {
pub page_prev: bool,
pub toggle_sidebar: bool,
pub jump_to_section: Option<usize>,
pub jump_to_anchor: Option<String>,
}
pub fn reading_view(
@@ -239,6 +261,7 @@ pub fn reading_view(
page_prev: false,
toggle_sidebar: false,
jump_to_section: None,
jump_to_anchor: None,
};
let mut jump_to_bookmark: Option<usize> = None;
@@ -271,8 +294,9 @@ pub fn reading_view(
ui.separator();
if sidebar_tab == 0 {
egui::ScrollArea::vertical().show(ui, |ui| {
if let Some(section) = render_toc(ui, &book.toc, *current_section) {
if let Some((section, anchor)) = render_toc(ui, &book.toc, *current_section) {
action.jump_to_section = Some(section);
action.jump_to_anchor = anchor;
}
});
} else {
@@ -549,8 +573,8 @@ fn render_toc(
ui: &mut egui::Ui,
entries: &[crate::book::TocEntry],
current_section: usize,
) -> Option<usize> {
let mut jump = None;
) -> Option<(usize, Option<String>)> {
let mut jump: Option<(usize, Option<String>)> = None;
for entry in entries {
let label_text = egui::RichText::new(&entry.label);
let response = ui.add(
@@ -559,9 +583,10 @@ fn render_toc(
.wrap()
);
if response.clicked() {
jump = Some(entry.section);
jump = Some((entry.section, entry.anchor.clone()));
}
response.on_hover_text(format!("跳转到: {} (章节 {})", entry.label, entry.section));
let anchor_info = entry.anchor.as_ref().map(|a| format!(" @{a}")).unwrap_or_default();
response.on_hover_text(format!("跳转到: {} (章节 {}{})", entry.label, entry.section, anchor_info));
if !entry.children.is_empty() {
ui.indent(&entry.label, |ui| {
if let Some(s) = render_toc(ui, &entry.children, current_section) {