Skip to main content

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}