R4.4: export and copy read as the visual mode reads

HTML export, plain text and rich copy read each paragraph, heading,
cell and HTML block through the component's MarkdownReader (pin
1251221). Emoji, alerts, the TOC and inline tags match visual mode.
Footnotes sit in place as [n]; link titles are not written.

Three review rounds failed; round 3's one finding (a scan per
footnote over open marks) is fixed with a growth regression, unreviewed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 09:11:47 -04:00
1 parent 9d80ba6f39
commit 256240c365
16 files changed
+728 -192

No files matched your search

+57 -21
View File
@@ -2,21 +2,44 @@
use std::io;
use ely_gpui_component::documents::markdown_options;
use ely_gpui_component::documents::{
MarkdownBlock, MarkdownReader, alert, is_toc, markdown_options,
};
use markdown::mdast::Node;
/// `text` as plain text, a blank line between blocks.
pub fn plain(text: &str) -> io::Result<String> {
let tree = markdown::to_mdast(text, &markdown_options())
.map_err(|error| io::Error::other(error.to_string()))?;
let reader = MarkdownReader::new(text, &tree);
let mut blocks = Vec::new();
// Alerts and `[toc]` live only at the root.
for child in tree.children().into_iter().flatten() {
block(child, &mut blocks);
match child {
Node::Blockquote(quote) => match alert(quote) {
Some((kind, body)) => {
blocks.push(kind.title().to_string());
for node in &body {
block(&reader, node, &mut blocks);
}
}
None => block(&reader, child, &mut blocks),
},
_ if is_toc(child, text) => {
let titles: Vec<&str> = (reader.headings.iter())
.map(|heading| heading.title.as_str())
.collect();
if !titles.is_empty() {
blocks.push(titles.join("\n"));
}
}
_ => block(&reader, child, &mut blocks),
}
}
Ok(blocks.join("\n\n") + "\n")
}
fn block(node: &Node, blocks: &mut Vec<String>) {
fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
match node {
Node::Code(code) => blocks.push(code.value.clone()),
Node::Math(math) => blocks.push(math.value.clone()),
@@ -24,7 +47,7 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
let items = list.children.iter().map(|item| {
let mut inner = Vec::new();
for child in item.children().into_iter().flatten() {
block(child, &mut inner);
block(reader, child, &mut inner);
}
inner.join("\n")
});
@@ -32,32 +55,45 @@ fn block(node: &Node, blocks: &mut Vec<String>) {
}
Node::Table(table) => {
let rows = table.children.iter().map(|row| {
let cells = row.children().into_iter().flatten().map(words);
let cells = row.children().into_iter().flatten();
let cells = cells.map(|cell| words(reader, cell));
cells.collect::<Vec<_>>().join("\t")
});
blocks.push(rows.collect::<Vec<_>>().join("\n"));
}
Node::Blockquote(_) | Node::FootnoteDefinition(_) => {
Node::FootnoteDefinition(note) => {
let mut body = Vec::new();
for child in &note.children {
block(reader, child, &mut body);
}
// The label the drawing shows beside it.
let label = format!("[{}]", reader.label(note));
blocks.push([label, body.join("\n\n")].join(" ").trim_end().to_string());
}
Node::Blockquote(_) => {
for child in node.children().into_iter().flatten() {
block(child, blocks);
block(reader, child, blocks);
}
}
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)),
// Front matter, HTML, rules and definitions are not words.
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)),
Node::Html(_) => {
let mut read = Vec::new();
reader.block(node, &mut read);
for piece in read {
match piece {
MarkdownBlock::Paragraph(text) => blocks.push(text.text),
other => unreachable!("an HTML block reads as text, not {other:?}"),
}
}
}
// Front matter, rules and definitions are not words.
_ => {}
}
}
/// The words of an inline run.
fn words(node: &Node) -> String {
match node {
Node::Text(text) => text.value.clone(),
Node::InlineCode(code) => code.value.clone(),
Node::InlineMath(math) => math.value.clone(),
Node::Break(_) => "\n".into(),
Node::Image(image) => image.alt.clone(),
Node::ImageReference(image) => image.alt.clone(),
Node::Html(_) => String::new(),
_ => node.children().into_iter().flatten().map(words).collect(),
}
/// An inline run's words, as the drawing reads them.
fn words(reader: &MarkdownReader, node: &Node) -> String {
reader
.inline(node.children().expect("an inline run holds inlines"))
.text
}