text_document/link_extent.rs
1//! Finding the full reach of one hyperlink.
2//!
3//! A link is a character format, not an object: it has no identity and no
4//! boundary of its own, only a stretch of runs that happen to agree about
5//! `anchor_href`. So "the link under the caret" is a question about *extent*,
6//! and answering it is the one piece of link handling with no existing API.
7//!
8//! ## Why coalescing is the whole job
9//!
10//! Format runs split on **any** field difference, so bolding one word inside a
11//! link cuts that link into three runs carrying the same destination. Reporting
12//! only the piece under the caret would silently truncate the link to whichever
13//! third the caret happened to land in — and an "Edit link" built on that
14//! answer would rewrite a third of the writer's text. The extent therefore
15//! walks outward from the piece under the caret and absorbs every neighbour
16//! that agrees on the destination.
17//!
18//! Two links that merely sit next to each other stay separate, because they
19//! disagree about `anchor_href` — which is also why the comparison is on the
20//! destination and not on `is_anchor`.
21
22use crate::flow::AddressablePiece;
23use crate::text_block::TextBlock;
24use frontend::common::format_runs::InlineContent;
25
26/// One hyperlink's full reach, in the document's addressable character space.
27///
28/// `start`/`end` are document-relative — the same space `TextCursor::position`,
29/// `select_range` and `find_all` matches use — so a caller can select the
30/// extent without converting anything.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct LinkExtent {
33 /// `[start, end)` in the document's addressable character space.
34 pub start: usize,
35 pub end: usize,
36 /// The link's destination.
37 pub href: String,
38 /// The text the link covers — what a writer sees as the link's name.
39 pub text: String,
40}
41
42/// The destination a piece carries, if it is link text.
43///
44/// Images and footnote references are skipped even when they sit inside a
45/// link's range: they occupy a `U+FFFC` sentinel rather than text, and letting
46/// one join the extent would put a sentinel character into `text`, which the
47/// caller then writes back as literal prose.
48fn href_of(piece: &AddressablePiece) -> Option<&str> {
49 match piece.content {
50 InlineContent::Text(_) => piece.format.anchor_href.as_deref(),
51 _ => None,
52 }
53}
54
55/// The link covering `position`, or `None` if there is no link there.
56///
57/// `position` is document-relative. A caret sitting on either edge of a link
58/// counts as inside it, matching the caret semantics
59/// [`crate::inner::block_at_caret_dto`] applies one level up: a caret at the
60/// end of a link is still in it, exactly as a caret at the end of a paragraph
61/// is still in that paragraph.
62pub(crate) fn link_extent_at(block: &TextBlock, position: usize) -> Option<LinkExtent> {
63 let pieces = block.addressable_inline_pieces();
64
65 // The piece under the caret. Inclusive of both edges, so a caret between
66 // two pieces prefers the one it closes rather than the one it opens —
67 // `rposition` picks the later candidate only when the earlier one does not
68 // reach the caret at all.
69 let hit = pieces
70 .iter()
71 .rposition(|p| p.start <= position && position <= p.end)?;
72
73 // A caret on a boundary can touch a plain piece and a link piece at once.
74 // Prefer the link: the writer who clicked the end of a link means that
75 // link, and there is no competing interpretation — a plain run has nothing
76 // to offer this query.
77 let anchor = [hit, hit.saturating_sub(1), (hit + 1).min(pieces.len())]
78 .into_iter()
79 .filter(|&i| i < pieces.len())
80 .find(|&i| {
81 href_of(&pieces[i]).is_some()
82 && pieces[i].start <= position
83 && position <= pieces[i].end
84 })?;
85
86 let href = href_of(&pieces[anchor])?.to_string();
87
88 // Walk outward while the destination holds. This is the coalescing the
89 // module doc describes: bold inside a link splits the runs, and every one
90 // of those splinters belongs to the same link.
91 let mut first = anchor;
92 while first > 0 && href_of(&pieces[first - 1]) == Some(href.as_str()) {
93 first -= 1;
94 }
95 let mut last = anchor;
96 while last + 1 < pieces.len() && href_of(&pieces[last + 1]) == Some(href.as_str()) {
97 last += 1;
98 }
99
100 let text = pieces[first..=last]
101 .iter()
102 .filter_map(|p| match &p.content {
103 InlineContent::Text(t) => Some(t.as_str()),
104 _ => None,
105 })
106 .collect::<String>();
107
108 Some(LinkExtent {
109 start: pieces[first].start,
110 end: pieces[last].end,
111 href,
112 text,
113 })
114}