tiptap_rusty_parser/pos_map.rs
1//! Flat-position mapping: carry a position or range through a batch of edits.
2//!
3//! A [`PosMap`] is a ProseMirror-style position map built from the replaced
4//! spans of a disjoint edit batch (the `(from, old_len, new_len)` deltas a
5//! [`PosEdit`](crate::PosEdit) batch produces). [`PosMap::map`] carries a flat
6//! position from the **pre-edit** coordinate system to the **post-edit** one —
7//! so a cursor, selection, decoration, or streamed-suggestion range stays
8//! anchored across an [`apply_pos_edits`](crate::Node::apply_pos_edits) call.
9//!
10//! ```
11//! use tiptap_rusty_parser::{Assoc, Node, PosContent, PosEdit};
12//!
13//! // doc > paragraph("hello world"); insert "big " before "world" (pos 7).
14//! let mut doc = Node::element("doc")
15//! .with_child(Node::element("paragraph").with_child(Node::text("hello world")));
16//! let (_patch, map) = doc
17//! .apply_pos_edits_mapped(&[PosEdit::Insert {
18//! pos: 7,
19//! content: PosContent::Text { text: "big ".into(), marks: None },
20//! }])
21//! .unwrap();
22//!
23//! assert_eq!(map.map(3, Assoc::Left), 3); // before the edit: unchanged
24//! assert_eq!(map.map(7, Assoc::Right), 11); // at the edit, after inserted text
25//! assert_eq!(map.map(13, Assoc::Left), 17); // after the edit: shifted by +4
26//! ```
27
28use crate::pos::PosRange;
29use crate::pos_edit::PosEdit;
30use serde::{Deserialize, Serialize};
31
32/// Which edge a position lying inside a replaced span maps to.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub enum Assoc {
36 /// Bias toward the **start** of the replacement (left edge). The default —
37 /// a position stays before content inserted at it.
38 #[default]
39 Left,
40 /// Bias toward the **end** of the replacement (right edge) — a position
41 /// moves past content inserted at it.
42 Right,
43}
44
45/// One replaced span in **old** coordinates: `[start, start + old_len)` became
46/// `new_len` units wide.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48struct Step {
49 start: usize,
50 old_len: usize,
51 new_len: usize,
52}
53
54/// A flat position map over a batch of **disjoint** replacements, mapping
55/// pre-edit positions to post-edit ones. See the [module docs](self).
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct PosMap {
58 /// Steps in old coordinates, kept sorted ascending by `start`. Callers are
59 /// expected to keep them disjoint (`push` does not enforce it); overlapping
60 /// steps still map safely but to unspecified edges.
61 steps: Vec<Step>,
62}
63
64impl PosMap {
65 /// An empty map (the identity: every position maps to itself).
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 /// Whether this map is the identity — it holds no steps, so every position
71 /// maps to itself. (A same-length step still counts: it maps interior
72 /// positions to an edge, so it is *not* empty.)
73 pub fn is_empty(&self) -> bool {
74 self.steps.is_empty()
75 }
76
77 /// Record a replacement: `[start, start + old_len)` (old coords) became
78 /// `new_len` units. A no-op span (`old_len == new_len == 0`) is ignored.
79 /// Steps are expected disjoint; they're kept sorted by `start`.
80 pub fn push(&mut self, start: usize, old_len: usize, new_len: usize) {
81 if old_len == 0 && new_len == 0 {
82 return;
83 }
84 let step = Step {
85 start,
86 old_len,
87 new_len,
88 };
89 let i = self.steps.partition_point(|s| s.start < start);
90 self.steps.insert(i, step);
91 }
92
93 /// Map a flat position from pre-edit to post-edit coordinates. `assoc`
94 /// decides which edge a position inside a replaced span lands on.
95 pub fn map(&self, pos: usize, assoc: Assoc) -> usize {
96 let mut diff: i64 = 0;
97 for s in &self.steps {
98 if pos < s.start {
99 break; // sorted: this step and all later ones start after `pos`
100 }
101 let old_end = s.start + s.old_len;
102 if pos > old_end {
103 diff += s.new_len as i64 - s.old_len as i64;
104 continue;
105 }
106 // `s.start <= pos <= old_end`: inside (or at a boundary of) the span.
107 let into = match assoc {
108 Assoc::Left => 0,
109 Assoc::Right => s.new_len as i64,
110 };
111 return (s.start as i64 + diff + into) as usize;
112 }
113 (pos as i64 + diff) as usize
114 }
115
116 /// Map both endpoints of a [`PosRange`] with the same `assoc` (so a
117 /// collapsed range stays collapsed).
118 pub fn map_range(&self, range: PosRange, assoc: Assoc) -> PosRange {
119 PosRange::new(self.map(range.from, assoc), self.map(range.to, assoc))
120 }
121
122 /// Build a map from a **disjoint** batch of edits (the same batch passed to
123 /// [`apply_pos_edits`](crate::Node::apply_pos_edits)). Positions are taken in
124 /// the pre-edit coordinate system; mark/attr edits contribute no step.
125 pub fn from_pos_edits(edits: &[PosEdit]) -> Self {
126 let mut map = PosMap::new();
127 for e in edits {
128 match e {
129 PosEdit::Insert { pos, content } => map.push(*pos, 0, content.flat_len()),
130 PosEdit::Delete { from, to } => map.push(*from, to.saturating_sub(*from), 0),
131 PosEdit::Replace { from, to, content } => {
132 map.push(*from, to.saturating_sub(*from), content.flat_len())
133 }
134 // Marks and attrs don't change flat sizes.
135 PosEdit::AddMark { .. }
136 | PosEdit::RemoveMark { .. }
137 | PosEdit::SetBlockAttrs { .. } => {}
138 }
139 }
140 map
141 }
142}