diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index bd8e82bb2..cadc3d419 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -793,8 +793,120 @@ impl Document { st.editorial.clear(); st.fix_marks(); st.rev = st.rev.wrapping_add(1); + self.prune_story_text_ranges(sid); Ok(()) } + + /// Prune text-anchored hyperlinks and placeholders for `story` (e.g. after wholesale story replacement). + pub fn prune_story_text_ranges(&mut self, story: StoryId) { + self.hyperlinks.retain(|h| match &h.source { + crate::HyperlinkSource::Text { story: sid, .. } => *sid != story, + _ => true, + }); + self.data_merge.placeholders.retain(|ph| match &ph.anchor { + crate::PlaceholderAnchor::Text { story: sid, .. } => *sid != story, + _ => true, + }); + } + + /// Remaps text range `s..e` when `del_range` is replaced by `ins_len` bytes. + /// Returns `None` if the range is inverted (`s > e`) or emptied by the edit (`s < e && s_new >= e_new`). + pub fn remap_range(del_range: std::ops::Range, ins_len: usize, s: usize, e: usize) -> Option<(usize, usize)> { + if s > e { + return None; + } + let (da, db) = (del_range.start, del_range.end); + let del_before = db.min(s).saturating_sub(da); + let del_inside = db.min(e).saturating_sub(da.max(s)); + let mut s_new = s.saturating_sub(del_before); + let mut e_new = e.saturating_sub(del_before).saturating_sub(del_inside); + if da < s || (da == s && db == s) { + s_new = s_new.saturating_add(ins_len); + e_new = e_new.saturating_add(ins_len); + } else if da < e { + e_new = e_new.saturating_add(ins_len); + } + if s < e && s_new >= e_new { None } else { Some((s_new, e_new)) } + } + + /// Adjust story-external text references (hyperlinks and data-merge placeholders) after text + /// in `story` has `del_range` replaced by `ins_len` bytes. + pub fn adjust_text_ranges(&mut self, story: StoryId, del_range: std::ops::Range, ins_len: usize) { + self.hyperlinks.retain_mut(|h| { + if let crate::HyperlinkSource::Text { story: sid, start, end } = &mut h.source + && *sid == story + { + if let Some((ns, ne)) = Self::remap_range(del_range.clone(), ins_len, *start, *end) { + *start = ns; + *end = ne; + return true; + } + return false; + } + true + }); + + self.data_merge.placeholders.retain_mut(|ph| { + if let crate::PlaceholderAnchor::Text { story: sid, start, end } = &mut ph.anchor + && *sid == story + { + if let Some((ns, ne)) = Self::remap_range(del_range.clone(), ins_len, *start, *end) { + *start = ns; + *end = ne; + return true; + } + return false; + } + true + }); + } + + /// Shifts text ranges when a block of text in `story` is moved from `from` to `to` (the insertion offset after deletion). + /// Preserves the existing order of items in `hyperlinks` and `placeholders`. + pub fn move_text_ranges(&mut self, story: StoryId, from: std::ops::Range, to: usize) { + if from.is_empty() { + return; + } + let (da, db) = (from.start, from.end); + let len = db - da; + + let remap_moved = |s: usize, e: usize| -> Option<(usize, usize)> { + if s >= da && e <= db { + Some((to + (s - da), to + (e - da))) + } else { + let (s1, e1) = Self::remap_range(da..db, 0, s, e)?; + Self::remap_range(to..to, len, s1, e1) + } + }; + + self.hyperlinks.retain_mut(|h| { + if let crate::HyperlinkSource::Text { story: sid, start, end } = &mut h.source + && *sid == story + { + if let Some((ns, ne)) = remap_moved(*start, *end) { + *start = ns; + *end = ne; + return true; + } + return false; + } + true + }); + + self.data_merge.placeholders.retain_mut(|ph| { + if let crate::PlaceholderAnchor::Text { story: sid, start, end } = &mut ph.anchor + && *sid == story + { + if let Some((ns, ne)) = remap_moved(*start, *end) { + *start = ns; + *end = ne; + return true; + } + return false; + } + true + }); + } } /// How deep nested stories (a cell's text, a footnote's text, an endnote's text) are followed diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 44926d51b..08a525251 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -370,6 +370,185 @@ fn placed_pdf_without_a_crop_choice_loads_with_the_crop_box() { assert_eq!(Asset { pdf_box: Some([0.5, 0.5, 0.5, 0.9]), ..back }.shown_box(), None); } +#[test] +fn adjust_text_ranges_tracks_and_prunes_hyperlinks_and_placeholders() { + let mut d = doc(); + let sid = StoryId(1); + d.hyperlinks.push(Hyperlink { + id: 10, + name: "Link".into(), + source: HyperlinkSource::Text { story: sid, start: 10, end: 20 }, + dest: HyperlinkDest::Url("https://example.com".into()), + }); + d.data_merge.placeholders.push(crate::Placeholder { + id: 20, + source_id: 1, + field: "Name".into(), + role: crate::PlaceholderRole::Text, + anchor: crate::PlaceholderAnchor::Text { story: sid, start: 10, end: 20 }, + }); + + // 1. Insert before range (at 5, length 3) -> both move by 3: 13..23 + d.adjust_text_ranges(sid, 5..5, 3); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 13, end: 23 }); + assert_eq!(d.data_merge.placeholders[0].anchor, crate::PlaceholderAnchor::Text { story: sid, start: 13, end: 23 }); + + // 2. Pure insert at start (at 13, length 2) -> shifts forward: 15..25 + d.adjust_text_ranges(sid, 13..13, 2); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 15, end: 25 }); + + // 3. Insert inside range (at 18, length 4) -> end expands: 15..29 + d.adjust_text_ranges(sid, 18..18, 4); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 15, end: 29 }); + + // 4. Typing at the very end of the range (at 29, length 3) -> does not expand range + d.adjust_text_ranges(sid, 29..29, 3); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 15, end: 29 }); + + // 5. Insert after range (at 35, length 5) -> no change + d.adjust_text_ranges(sid, 35..35, 5); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 15, end: 29 }); + + // 6. Delete before range (0..5, length 0) -> shifts back by 5: 10..24 + d.adjust_text_ranges(sid, 0..5, 0); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 10, end: 24 }); + + // 7. Delete inside range (12..16, length 0) -> end shrinks by 4: 10..20 + d.adjust_text_ranges(sid, 12..16, 0); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 10, end: 20 }); + + // 8. Delete across start boundary (8..14, length 0) -> surviving start is 8, end is 14 + d.adjust_text_ranges(sid, 8..14, 0); + assert_eq!(d.hyperlinks[0].source, HyperlinkSource::Text { story: sid, start: 8, end: 14 }); + + // 9. Delete entire range (5..20, length 0) -> pruned + d.adjust_text_ranges(sid, 5..20, 0); + assert!(d.hyperlinks.is_empty()); + assert!(d.data_merge.placeholders.is_empty()); +} + +#[test] +fn adjust_text_ranges_preserves_zero_length_ranges_and_drops_inverted() { + let mut d = doc(); + let sid = StoryId(1); + // Zero-length placeholder (e.g. created at caret) + d.data_merge.placeholders.push(crate::Placeholder { + id: 1, + source_id: 1, + field: "CaretPh".into(), + role: crate::PlaceholderRole::Hyperlink, + anchor: crate::PlaceholderAnchor::Text { story: sid, start: 10, end: 10 }, + }); + // Inverted hyperlink from corrupt input + d.hyperlinks.push(Hyperlink { + id: 2, + name: "Inverted".into(), + source: HyperlinkSource::Text { story: sid, start: 25, end: 15 }, + dest: HyperlinkDest::Url("https://example.com".into()), + }); + + // 1. Inverted range is pruned on edit + d.adjust_text_ranges(sid, 0..0, 2); + assert!(d.hyperlinks.is_empty(), "inverted range must be dropped"); + + // 2. Zero-length placeholder shifts when text is inserted before it (at 0, len 5) -> 15..15 + assert_eq!(d.data_merge.placeholders[0].anchor, crate::PlaceholderAnchor::Text { story: sid, start: 12, end: 12 }); + d.adjust_text_ranges(sid, 0..0, 5); + assert_eq!(d.data_merge.placeholders[0].anchor, crate::PlaceholderAnchor::Text { story: sid, start: 17, end: 17 }); + + // 3. Zero-length placeholder shifts when text is deleted before it (0..7) -> 10..10 + d.adjust_text_ranges(sid, 0..7, 0); + assert_eq!(d.data_merge.placeholders[0].anchor, crate::PlaceholderAnchor::Text { story: sid, start: 10, end: 10 }); + + // 4. Deleting text enclosing the zero-length placeholder collapses it to the deletion start without pruning + d.adjust_text_ranges(sid, 5..15, 0); + assert_eq!(d.data_merge.placeholders[0].anchor, crate::PlaceholderAnchor::Text { story: sid, start: 5, end: 5 }); +} + +#[test] +fn move_text_ranges_tracks_hyperlinks_and_placeholders() { + let mut d = doc(); + let sid = StoryId(1); + d.hyperlinks.push(Hyperlink { + id: 1, + name: "Inside".into(), + source: HyperlinkSource::Text { story: sid, start: 22, end: 28 }, + dest: HyperlinkDest::Url("https://example.com/inside".into()), + }); + d.hyperlinks.push(Hyperlink { + id: 2, + name: "Between".into(), + source: HyperlinkSource::Text { story: sid, start: 5, end: 10 }, + dest: HyperlinkDest::Url("https://example.com/between".into()), + }); + + // Move block 20..30 to position 0 (before both links). + // The moved block (length 10) lands at 0..10. + // The link inside (22..28, relative offset +2..+8) becomes 2..8. + // The link originally at 5..10 shifts forward by 10 to 15..20. + d.move_text_ranges(sid, 20..30, 0); + + let inside = d.hyperlinks.iter().find(|h| h.id == 1).unwrap(); + let between = d.hyperlinks.iter().find(|h| h.id == 2).unwrap(); + assert_eq!(inside.source, HyperlinkSource::Text { story: sid, start: 2, end: 8 }); + assert_eq!(between.source, HyperlinkSource::Text { story: sid, start: 15, end: 20 }); + assert_eq!(d.hyperlinks[0].id, 1, "original list order in doc.hyperlinks must be preserved"); + assert_eq!(d.hyperlinks[1].id, 2); +} + +#[test] +fn set_story_text_prunes_story_hyperlinks_and_placeholders() { + let mut d = doc(); + let lid = d.default_layer(); + let (_, sid1) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 100.0, 100.0), lid, "initial one", ParaFormat::default()).unwrap(); + let (_, sid2) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(110.0, 10.0, 200.0, 100.0), lid, "initial two", ParaFormat::default()).unwrap(); + + d.hyperlinks.push(Hyperlink { + id: 1, + name: "Link1".into(), + source: HyperlinkSource::Text { story: sid1, start: 0, end: 7 }, + dest: HyperlinkDest::Url("https://example.com/1".into()), + }); + d.hyperlinks.push(Hyperlink { + id: 2, + name: "Link2".into(), + source: HyperlinkSource::Text { story: sid2, start: 0, end: 7 }, + dest: HyperlinkDest::Url("https://example.com/2".into()), + }); + d.data_merge.placeholders.push(crate::Placeholder { + id: 10, + source_id: 1, + field: "Ph1".into(), + role: crate::PlaceholderRole::Text, + anchor: crate::PlaceholderAnchor::Text { story: sid1, start: 0, end: 7 }, + }); + d.data_merge.placeholders.push(crate::Placeholder { + id: 20, + source_id: 1, + field: "Ph2".into(), + role: crate::PlaceholderRole::Text, + anchor: crate::PlaceholderAnchor::Text { story: sid2, start: 0, end: 7 }, + }); + + d.set_story_text(sid1, "Replacement story text").unwrap(); + + // sid1 hyperlinks and placeholders must be pruned + assert!(d.hyperlinks.iter().all(|h| match &h.source { + HyperlinkSource::Text { story, .. } => *story != sid1, + _ => true, + })); + assert!(d.data_merge.placeholders.iter().all(|ph| match &ph.anchor { + crate::PlaceholderAnchor::Text { story, .. } => *story != sid1, + _ => true, + })); + + // sid2 hyperlinks and placeholders must be preserved + assert_eq!(d.hyperlinks.len(), 1); + assert_eq!(d.hyperlinks[0].id, 2); + assert_eq!(d.data_merge.placeholders.len(), 1); + assert_eq!(d.data_merge.placeholders[0].id, 20); +} + /// Exceptions are read in one pass: breaks are char positions of the lowercased word, a break /// at the start, at the end or twice in a row counts once or not at all, and a long exception /// full of breaks takes no longer than its length. diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index 1e00585ca..48bb8bb85 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -125,12 +125,14 @@ pub(crate) fn check_item(it: &Item) -> Result<()> { /// Anchor copies of `items` (in spread coordinates) at `pos` of `sid`; returns the end position. pub(crate) fn anchor_items(d: &mut Document, sid: StoryId, pos: usize, items: Vec, position: &AnchorPosition) -> Result { + let count = items.len(); let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let mut at = pos.min(st.len()); for it in items { st.insert_object(at, AnchoredObject::new(it, position.clone())); at += OBJECT_MARK.len_utf8(); } + d.adjust_text_ranges(sid, pos..pos, count * OBJECT_MARK.len_utf8()); Ok(at) } @@ -229,7 +231,9 @@ fn release(s: &mut Session, p: &Value) -> Result { } let id = d.insert_item(spread, it, None)?; let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - st.delete(pos..pos + OBJECT_MARK.len_utf8()); + let len = OBJECT_MARK.len_utf8(); + st.delete(pos..pos + len); + d.adjust_text_ranges(sid, pos..pos + len, 0); *sel = designcraft_doc::Selection::items(vec![id]); Ok(json!({"id": id.0})) }) diff --git a/crates/engine/src/cmd/captions.rs b/crates/engine/src/cmd/captions.rs index d35dc3e60..c2fbbc6ee 100644 --- a/crates/engine/src/cmd/captions.rs +++ b/crates/engine/src/cmd/captions.rs @@ -131,6 +131,7 @@ pub(crate) fn refresh_live(d: &mut Document) -> bool { let len = st.len(); st.replace(0..len, &text); } + d.prune_story_text_ranges(sid); } changed } diff --git a/crates/engine/src/cmd/changes.rs b/crates/engine/src/cmd/changes.rs index e471c4b6e..328bcf186 100644 --- a/crates/engine/src/cmd/changes.rs +++ b/crates/engine/src/cmd/changes.rs @@ -1,16 +1,16 @@ //! Type › Track Changes: record edits as inserted / deleted text; accept or reject them. -use designcraft_doc::{ChangeMark, Story, StoryId}; +use designcraft_doc::{ChangeMark, Document, Story, StoryId}; use serde_json::{Value, json}; use super::{CommandSpec, cmd, has_doc}; use crate::{Result, Session}; -/// Mark `r` deleted (text this session inserted while tracking is simply removed). Returns where -/// the text after the range now starts. -pub(crate) fn mark_deleted(st: &mut Story, r: std::ops::Range) -> usize { +/// Returns the new start of text after `r` and the list of physically removed byte ranges (in original offsets, last first) +/// so callers can reconcile document-level text ranges. +pub(crate) fn mark_deleted_reporting(st: &mut Story, r: std::ops::Range) -> (usize, Vec>) { if r.is_empty() { - return r.start; + return (r.start, Vec::new()); } // Inserted pieces inside the range go away; the rest is marked deleted. let mut ins: Vec> = st @@ -20,6 +20,7 @@ pub(crate) fn mark_deleted(st: &mut Story, r: std::ops::Range) -> usize { .collect(); let mut end = r.end; ins.sort_by_key(|x| std::cmp::Reverse(x.start)); + let removed = ins.clone(); for x in ins { st.delete(x.clone()); end -= x.len(); @@ -27,7 +28,14 @@ pub(crate) fn mark_deleted(st: &mut Story, r: std::ops::Range) -> usize { if end > r.start { st.format_chars(r.start..end, |f| f.over.change = Some(ChangeMark::Deleted)); } - end + (end, removed) +} + +/// Mark `r` deleted (text this session inserted while tracking is simply removed). Returns where +/// the text after the range now starts. +#[cfg(test)] +pub(crate) fn mark_deleted(st: &mut Story, r: std::ops::Range) -> usize { + mark_deleted_reporting(st, r).0 } /// Ranges of `mark` in `st`, last first. @@ -37,20 +45,31 @@ fn ranges(st: &Story, mark: ChangeMark) -> Vec> { v } -/// Accept (or reject) every change in `st`. -fn resolve(st: &mut Story, accept: bool) -> usize { +/// Accept (or reject) every change in `sid`. +fn resolve(d: &mut Document, sid: StoryId, accept: bool) -> usize { let (remove, keep) = if accept { (ChangeMark::Deleted, ChangeMark::Inserted) } else { (ChangeMark::Inserted, ChangeMark::Deleted) }; - let removed = ranges(st, remove); - let n = removed.len() + ranges(st, keep).len(); - for r in removed { - st.delete(r); - } - let len = st.len(); - st.format_chars(0..len, |f| { - if f.over.change.is_some() { - f.over.change = None; + let (removed, keep_count) = { + let st = match d.story(sid) { + Some(st) => st, + None => return 0, + }; + (ranges(st, remove), ranges(st, keep).len()) + }; + let n = removed.len() + keep_count; + if let Some(st) = d.story_mut(sid) { + for r in &removed { + st.delete(r.clone()); } - }); + let len = st.len(); + st.format_chars(0..len, |f| { + if f.over.change.is_some() { + f.over.change = None; + } + }); + } + for r in removed { + d.adjust_text_ranges(sid, r, 0); + } n } @@ -60,17 +79,24 @@ fn one(s: &mut Session, p: &Value, accept: bool) -> Result { let sid = p.get("story").and_then(Value::as_u64).map(StoryId).ok_or_else(|| super::bad(id, "`story` required"))?; let start = p.get("start").and_then(Value::as_u64).ok_or_else(|| super::bad(id, "`start` required"))? as usize; s.edit(|d, sel| { - let st = d.story_mut(sid).ok_or_else(|| super::bad(id, "no such story"))?; - let (r, mark) = st - .runs() - .find(|(r, f)| matches!(f.over.change, Some(ChangeMark::Inserted | ChangeMark::Deleted)) && r.start <= start && start < r.end) - .map(|(r, f)| (r, f.over.change.unwrap_or_default())) - .ok_or_else(|| super::bad(id, "no change there"))?; + let (r, mark) = { + let st = d.story(sid).ok_or_else(|| super::bad(id, "no such story"))?; + st.runs() + .find(|(r, f)| matches!(f.over.change, Some(ChangeMark::Inserted | ChangeMark::Deleted)) && r.start <= start && start < r.end) + .map(|(r, f)| (r, f.over.change.unwrap_or_default())) + .ok_or_else(|| super::bad(id, "no change there"))? + }; // Accepting a deletion or rejecting an insertion removes the text; otherwise it stays, unmarked. - if (mark == ChangeMark::Deleted) == accept { - st.delete(r.clone()); - } else { - st.format_chars(r.clone(), |f| f.over.change = None); + let delete = (mark == ChangeMark::Deleted) == accept; + if let Some(st) = d.story_mut(sid) { + if delete { + st.delete(r.clone()); + } else { + st.format_chars(r.clone(), |f| f.over.change = None); + } + } + if delete { + d.adjust_text_ranges(sid, r.clone(), 0); } if let Some(t) = &mut sel.text && t.story == sid @@ -137,9 +163,7 @@ fn all(s: &mut Session, p: &Value, accept: bool) -> Result { let ids: Vec = d.stories.keys().copied().filter(|id| only.is_none_or(|o| o == *id)).collect(); let mut n = 0; for id in ids { - if let Some(st) = d.story_mut(id) { - n += resolve(st, accept); - } + n += resolve(d, id, accept); } // The caret may now be past the end. if let Some(t) = &mut sel.text diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs index 4d3cd5dd6..5b42e6c1e 100644 --- a/crates/engine/src/cmd/datamerge.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -10,8 +10,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use designcraft_doc::{ - DataField, DataFieldKind, DataSource, Delimiter, Document, HyperlinkSource, ItemId, MergeOptions, Page, Placeholder, PlaceholderAnchor, - PlaceholderRole, Selection, SourceStatus, SpreadRef, StoryId, + DataField, DataFieldKind, DataSource, Delimiter, Document, ItemId, MergeOptions, Page, Placeholder, PlaceholderAnchor, PlaceholderRole, + Selection, SourceStatus, SpreadRef, StoryId, }; use designcraft_geom::{Rect, Vec2}; use serde_json::{Value, json}; @@ -172,8 +172,7 @@ fn placeholder_remove(s: &mut Session, p: &Value) -> Result { let start = start.min(end); st.replace(start..end, ""); } - let removed = end.saturating_sub(start); - shift_after(d, story, end, -(removed as isize)); + d.adjust_text_ranges(story, start..end, 0); } } d.data_merge.placeholders.retain(|ph| ph.id != id); @@ -495,7 +494,7 @@ fn add_placeholder(d: &mut Document, sel: &Selection, p: &Value, name: &str) -> if let Some(st) = d.story_mut(story) { st.replace(at..at, &marker); } - shift_after(d, story, at, marker.len() as isize); + d.adjust_text_ranges(story, at..at, marker.len()); PlaceholderAnchor::Text { story, start: at, end: at.saturating_add(marker.len()) } } else { let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default(); @@ -521,30 +520,6 @@ fn text_target(p: &Value, sel: &Selection) -> Option<(StoryId, usize, usize)> { }) } -fn shift_after(doc: &mut Document, story: StoryId, at: usize, delta: isize) { - if delta == 0 { - return; - } - for ph in &mut doc.data_merge.placeholders { - if let PlaceholderAnchor::Text { story: s, start, end } = &mut ph.anchor - && *s == story - && *start >= at - { - *start = fill::add_delta(*start, delta); - *end = fill::add_delta(*end, delta); - } - } - for link in &mut doc.hyperlinks { - if let HyperlinkSource::Text { story: s, start, end } = &mut link.source - && *s == story - && *start >= at - { - *start = fill::add_delta(*start, delta); - *end = fill::add_delta(*end, delta); - } - } -} - /// After an edit, drop a text placeholder whose marker is gone unless the story still has exactly /// one copy of it, and drop an item placeholder whose frame is gone. pub(crate) fn sync_placeholders(doc: &mut Document) { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 62b176cea..a87c9b501 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -101,7 +101,9 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< return s.edit(|d, sel| { let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); - st.delete(r.start.min(st.len())..r.end.min(st.len())); + let del = r.start.min(st.len())..r.end.min(st.len()); + st.delete(del.clone()); + d.adjust_text_ranges(t.story, del, 0); // Fresh ids, story and asset copies for every paste, as on the page. let mut stories = std::collections::HashMap::new(); let fresh: Vec = list @@ -125,12 +127,19 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< // Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes. if use_clip && let Some((slice, _)) = clip { let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() }; + let slice_len = slice.text.len(); return s.edit(|d, sel| { - let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let r = t.range(); - let r = r.start.min(st.len())..r.end.min(st.len()); - st.delete(r.clone()); - let end = st.insert_story(r.start, &slice); + let (end, r) = { + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let r = t.range(); + let r = r.start.min(st.len())..r.end.min(st.len()); + st.delete(r.clone()); + let end = st.insert_story(r.start, &slice); + (end, r) + }; + if t.cell.is_none() { + d.adjust_text_ranges(t.story, r, slice_len); + } sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); Ok(json!({"pos": end, "formatted": true})) }); diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 5b09cbcb6..7392d2da8 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -20,16 +20,19 @@ pub fn specs() -> Vec { let author = str_param(p, "author").unwrap_or("").to_string(); let at = ts.anchor.max(ts.focus); s.edit(|d, sel| { - let story = d.story_mut(ts.story).ok_or_else(|| bad("note.new", "no such story"))?; - let fmt = story.char_format_at(at).clone(); - story.insert_with(at, &NOTE_MARK.to_string(), fmt); - // A file may hold fewer notes than anchors; give every anchor one. - story.fix_marks(); - let k = story.text[..at].matches(NOTE_MARK).count(); - let n = story.editorial.get_mut(k).ok_or_else(|| bad("note.new", "the story's notes don't match their anchors"))?; - let n = Arc::make_mut(n); - (n.text, n.author) = (text, author); - let id = n.id; + let id = { + let story = d.story_mut(ts.story).ok_or_else(|| bad("note.new", "no such story"))?; + let fmt = story.char_format_at(at).clone(); + story.insert_with(at, &NOTE_MARK.to_string(), fmt); + // A file may hold fewer notes than anchors; give every anchor one. + story.fix_marks(); + let k = story.text[..at].matches(NOTE_MARK).count(); + let n = story.editorial.get_mut(k).ok_or_else(|| bad("note.new", "the story's notes don't match their anchors"))?; + let n = Arc::make_mut(n); + (n.text, n.author) = (text, author); + n.id + }; + d.adjust_text_ranges(ts.story, at..at, NOTE_MARK.len_utf8()); if let Some(t) = &mut sel.text { let c = at + NOTE_MARK.len_utf8(); (t.anchor, t.focus) = (c, c); @@ -68,8 +71,10 @@ pub fn specs() -> Vec { s.edit(|d, sel| { let st = d.story_mut(sid).ok_or_else(|| bad("note.delete", "no such story"))?; let at = anchor(st, id).ok_or_else(|| bad("note.delete", format!("no note {id}")))?; - follow_replace(sel, sid, at..at + NOTE_MARK.len_utf8(), 0); - st.delete(at..at + NOTE_MARK.len_utf8()); + let len = NOTE_MARK.len_utf8(); + follow_replace(sel, sid, at..at + len, 0); + st.delete(at..at + len); + d.adjust_text_ranges(sid, at..at + len, 0); ok() }) }), @@ -86,8 +91,10 @@ pub fn specs() -> Vec { let st = d.story_mut(sid).ok_or_else(|| bad("note.convertToText", "no such story"))?; let at = anchor(st, id).ok_or_else(|| bad("note.convertToText", format!("no note {id}")))?; let text = st.editorial.iter().find(|n| n.id == id).map(|n| n.text.clone()).unwrap_or_default(); - follow_replace(sel, sid, at..at + NOTE_MARK.len_utf8(), text.len()); - st.replace(at..at + NOTE_MARK.len_utf8(), &text); + let len = NOTE_MARK.len_utf8(); + follow_replace(sel, sid, at..at + len, text.len()); + st.replace(at..at + len, &text); + d.adjust_text_ranges(sid, at..at + len, text.len()); ok() }) } @@ -161,20 +168,23 @@ fn convert_from_text(s: &mut Session, p: &Value) -> Result { } let author = str_param(p, "author").unwrap_or("").to_string(); s.edit(|d, sel| { - let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?; - let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string(); - if text.contains(MARKS) { - return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker")); - } - let fmt = story.format_after(r.start).clone(); - story.delete(r.clone()); - story.insert_with(r.start, &NOTE_MARK.to_string(), fmt); - story.fix_marks(); - let k = story.text[..r.start].matches(NOTE_MARK).count(); - let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; - let n = Arc::make_mut(n); - (n.text, n.author) = (text, author); - let id = n.id; + let id = { + let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?; + let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string(); + if text.contains(MARKS) { + return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker")); + } + let fmt = story.format_after(r.start).clone(); + story.delete(r.clone()); + story.insert_with(r.start, &NOTE_MARK.to_string(), fmt); + story.fix_marks(); + let k = story.text[..r.start].matches(NOTE_MARK).count(); + let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; + let n = Arc::make_mut(n); + (n.text, n.author) = (text, author); + n.id + }; + d.adjust_text_ranges(ts.story, r.clone(), NOTE_MARK.len_utf8()); if let Some(t) = &mut sel.text { let c = r.start + NOTE_MARK.len_utf8(); (t.anchor, t.focus) = (c, c); diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index 9b02d2c87..decd6b097 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -40,8 +40,10 @@ pub fn specs() -> Vec { .nth(k) .map(|(i, _)| i) .ok_or_else(|| bad("endnote.delete", format!("endnote {id} has no reference in the text")))?; - super::notes::follow_delete(sel, sid, at..at + designcraft_doc::ENDNOTE_REF.len_utf8()); - st.delete(at..at + designcraft_doc::ENDNOTE_REF.len_utf8()); + let len = designcraft_doc::ENDNOTE_REF.len_utf8(); + super::notes::follow_delete(sel, sid, at..at + len); + st.delete(at..at + len); + d.adjust_text_ranges(sid, at..at + len, 0); ok() }) }), @@ -125,6 +127,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { let id = story .insert_endnote(at, &text, ParaFormat { style, ..Default::default() }) .ok_or_else(|| bad(ID, "the story's endnotes don't match its references"))?; + d.adjust_text_ranges(ts.story, at..at, designcraft_doc::ENDNOTE_REF.len_utf8()); let caret = at + designcraft_doc::ENDNOTE_REF.len_utf8(); if let Some(t) = &mut sel.text { (t.anchor, t.focus) = (caret, caret); diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index 371b89012..b9ab42550 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -659,7 +659,7 @@ fn change(s: &mut Session, p: &Value) -> Result { let Some(story) = d.text_story(sid, cell) else { continue }; // Collect matches first (positions refer to the original text), apply back to front. let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?; - let edits: Vec<(std::ops::Range, String)> = match (&found, &found_out) { + let mut edits: Vec<(std::ops::Range, String)> = match (&found, &found_out) { // Change: the found text alone, in the target it lies in. (Some((_, _, range)), Some(out)) => { if r.start <= range.start && range.end <= r.end { @@ -674,6 +674,7 @@ fn change(s: &mut Session, p: &Value) -> Result { .map(|(m, out)| (r.start + m.start..r.start + m.end, out)) .collect(), }; + edits.sort_by_key(|(range, _)| range.start); let Some(st) = d.text_story_mut(sid, cell) else { continue }; for (range, new) in edits.iter().rev() { let changed = if format_only { @@ -705,6 +706,11 @@ fn change(s: &mut Session, p: &Value) -> Result { if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { repair_selection(t, st); } + if cell.is_none() && !format_only { + for (range, new) in edits.iter().rev() { + d.adjust_text_ranges(sid, range.clone(), new.len()); + } + } // Change leaves the changed text selected. if let Some((_, _, range)) = &found && let Some((_, new)) = edits.first() diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs index 38a6bc49d..fa43d4d1b 100644 --- a/crates/engine/src/cmd/index.rs +++ b/crates/engine/src/cmd/index.rs @@ -88,6 +88,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result { let at = t.range().start.min(st.len()); st.insert_index_ref(at, r.clone()); let shift = INDEX_MARK.len_utf8(); + if t.cell.is_none() { + d.adjust_text_ranges(t.story, at..at, shift); + } sel.text = Some(TextSel { anchor: t.anchor + shift, focus: t.focus + shift, ..t }); Ok(json!({"pos": at})) }) @@ -184,14 +187,17 @@ fn fill_index(s: &mut Session, spec: IndexSpec, new_frame: Option<(usize, Option } }; let text = paras.iter().map(|(_, t)| t.as_str()).collect::>().join("\n"); - let st = d.story_mut(story).ok_or(designcraft_doc::DocError::NoStory(story))?; - let len = st.len(); - st.replace(0..len, &text); - for (pi, r) in st.para_ranges().into_iter().enumerate() { - if let Some((name, _)) = paras.get(pi) { - st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); + { + let st = d.story_mut(story).ok_or(designcraft_doc::DocError::NoStory(story))?; + let len = st.len(); + st.replace(0..len, &text); + for (pi, r) in st.para_ranges().into_iter().enumerate() { + if let Some((name, _)) = paras.get(pi) { + st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); + } } } + d.prune_story_text_ranges(story); d.index = Some(IndexSpec { story, ..spec.clone() }); Ok(json!({"story": story.0, "entries": paras.iter().filter(|(s, _)| s.starts_with("Index Level")).count()})) }) diff --git a/crates/engine/src/cmd/linked.rs b/crates/engine/src/cmd/linked.rs index 0b80e3aab..43695d1f5 100644 --- a/crates/engine/src/cmd/linked.rs +++ b/crates/engine/src/cmd/linked.rs @@ -13,6 +13,7 @@ fn copy_into(d: &mut Document, parent: StoryId, child: StoryId) -> Result<()> { let st = d.story_mut(child).ok_or_else(|| bad("story.link", format!("no story {}", child.0)))?; let (frames, rev, file_link) = (std::mem::take(&mut st.frames), st.rev, st.file_link.take()); *st = Story { id: child, frames, rev: rev.wrapping_add(1), link: Some((parent, src.rev)), file_link, ..src }; + d.prune_story_text_ranges(child); Ok(()) } diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index d6b4a9e95..0c8a120c3 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -85,9 +85,11 @@ fn insert(s: &mut Session, p: &Value) -> Result { if let Some(t) = text { let it = math_item(d, &latex, size, lid, (0.0, 0.0)); let r = t.range(); + let del = r.start.min(d.story(t.story).map_or(0, |st| st.len()))..r.end.min(d.story(t.story).map_or(0, |st| st.len())); if let Some(st) = d.story_mut(t.story) { - st.delete(r.start.min(st.len())..r.end.min(st.len())); + st.delete(del.clone()); } + d.adjust_text_ranges(t.story, del, 0); let end = super::anchored::anchor_items(d, t.story, r.start, vec![it], &Default::default())?; sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); return Ok(json!({"inline": true, "pos": end})); diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 008f0a98f..3c4552340 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -67,6 +67,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { let r = r.start.min(st.len())..r.end.min(st.len()); st.delete(r.clone()); let id = st.insert_note(r.start, &text, para).ok_or_else(|| bad("footnote.insert", "the story's footnotes don't match their references"))?; + d.adjust_text_ranges(t.story, r, designcraft_doc::FOOTNOTE_REF.len_utf8()); sel.text = Some(TextSel { story: t.story, anchor: text.len(), focus: text.len(), frame: t.frame, cell: Some(CellAddr::footnote(id)) }); Ok(json!({"story": t.story.0, "id": id})) })?; @@ -162,8 +163,10 @@ fn delete(s: &mut Session, p: &Value) -> Result { let moved = s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let at = st.note_anchor(id).ok_or_else(|| bad("footnote.delete", format!("no footnote {id}")))?; - follow_delete(sel, sid, at..at + designcraft_doc::FOOTNOTE_REF.len_utf8()); - st.delete(at..at + designcraft_doc::FOOTNOTE_REF.len_utf8()); + let len = designcraft_doc::FOOTNOTE_REF.len_utf8(); + follow_delete(sel, sid, at..at + len); + st.delete(at..at + len); + d.adjust_text_ranges(sid, at..at + len, 0); let moved = sel.text.is_some_and(|t| t.story == sid && t.cell == Some(CellAddr::footnote(id))); if moved { sel.text = Some(TextSel::caret(sid, at)); diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 852db171e..f80d3fba8 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -457,6 +457,8 @@ pub(super) fn place_text( if blank && let (Some(first), Some(p)) = (imp.story.paras.first(), story.paras.get_mut(p0)) { *p = ParaFormat { table: p.table, ..first.clone() }; } + let ins_len = end.saturating_sub(r.start); + d.adjust_text_ranges(t.story, r, ins_len); sel.text = Some(TextSel { anchor: end, focus: end, ..t }); if link.is_some() { warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into()); @@ -491,6 +493,7 @@ pub(super) fn place_text( } None => return Err(bad("file.place", "no such frame")), }; + d.prune_story_text_ranges(sid); let story = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; fill(story, &imp.story); story.file_link = link.as_deref().map(|path| text_link(path, bytes, before, map.clone(), conflict, remove, story)); diff --git a/crates/engine/src/cmd/spelling.rs b/crates/engine/src/cmd/spelling.rs index 5e7a425ea..4a43ab752 100644 --- a/crates/engine/src/cmd/spelling.rs +++ b/crates/engine/src/cmd/spelling.rs @@ -73,8 +73,13 @@ pub fn specs() -> Vec { let (a, b) = (p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, p.get("end").and_then(Value::as_u64).unwrap_or(0) as usize); let to = str_param(p, "to").unwrap_or("").to_string(); s.edit(|d, _| { - let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - st.replace(a.min(st.len())..b.min(st.len()), &to); + let r = { + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let r = a.min(st.len())..b.min(st.len()); + st.replace(r.clone(), &to); + r + }; + d.adjust_text_ranges(sid, r, to.len()); Ok(Value::Null) }) }), diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs index 0eb0302df..f969d96ef 100644 --- a/crates/engine/src/cmd/table/structure.rs +++ b/crates/engine/src/cmd/table/structure.rs @@ -67,8 +67,11 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { ts.apply_to(&mut table, cells); } let r = t.range(); + let len0 = st.len(); st.delete(r.clone()); st.insert_table(r.start, table); + let ins_len = st.len().saturating_sub(len0.saturating_sub(r.len())); + d.adjust_text_ranges(t.story, r, ins_len); *sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) }); Ok(json!({"story": t.story.0, "table": id})) }) @@ -147,8 +150,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { if let Some((ts, cells)) = &style { ts.apply_to(&mut table, cells); } + let len0 = st.len(); st.delete(a..b); let anchor = st.insert_table(a, table); + let ins_len = st.len().saturating_sub(len0.saturating_sub(b - a)); + d.adjust_text_ranges(t.story, a..b, ins_len); *sel = Selection::text(TextSel::caret(t.story, anchor)); Ok(json!({"table": id, "rows": data.len()})) }) @@ -162,7 +168,9 @@ pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result { let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default(); let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; - st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); + let len0 = designcraft_doc::TABLE_ANCHOR.len_utf8(); + st.replace(a..a + len0, &text); + d.adjust_text_ranges(g.story, a..a + len0, text.len()); *sel = Selection::text(TextSel::caret(g.story, a + text.len())); ok() }) @@ -171,22 +179,26 @@ pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result { pub(super) fn delete_table(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.delete")?; s.edit(|d, sel| { - let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?; - let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?; - // Remove the anchor paragraph (with one separator) so no empty paragraph is left behind. - let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8(); - let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') { - // Inserted at the end of the story: remove the empty paragraph after it too. - a - 1..end + 1 - } else if st.text[end..].starts_with('\n') { - a..end + 1 - } else if a > 0 && st.text[..a].ends_with('\n') { - a - 1..end - } else { - a..end + let (r, len) = { + let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?; + let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?; + // Remove the anchor paragraph (with one separator) so no empty paragraph is left behind. + let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8(); + let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') { + // Inserted at the end of the story: remove the empty paragraph after it too. + a - 1..end + 1 + } else if st.text[end..].starts_with('\n') { + a..end + 1 + } else if a > 0 && st.text[..a].ends_with('\n') { + a - 1..end + } else { + a..end + }; + st.delete(r.clone()); + (r, st.len()) }; - st.delete(r.clone()); - *sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len()))); + d.adjust_text_ranges(g.story, r.clone(), 0); + *sel = Selection::text(TextSel::caret(g.story, r.start.min(len))); ok() }) } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index eecdab5e3..bee08c167 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -175,17 +175,22 @@ pub fn specs() -> Vec { }; let text = super::text_param(p, "text"); s.edit(|d, sel| { - let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let n = st.len(); - if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) { - let msg = format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes"); - return Err(bad("story.replaceRange", msg)); - } - st.replace(a..b, &text); - let len = st.len(); + let len = { + let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let n = st.len(); + if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) { + let msg = + format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes"); + return Err(bad("story.replaceRange", msg)); + } + st.replace(a..b, &text); + st.len() + }; + d.adjust_text_ranges(sid, a..b, text.len()); if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { - t.anchor = floor_char_boundary(&st.text, t.anchor); - t.focus = floor_char_boundary(&st.text, t.focus); + let st_text = d.story(sid).map(|st| st.text.as_str()).unwrap_or(""); + t.anchor = floor_char_boundary(st_text, t.anchor); + t.focus = floor_char_boundary(st_text, t.focus); } Ok(json!({"length": len})) }) diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs index 0621e30de..29fa08cdc 100644 --- a/crates/engine/src/cmd/text/caret.rs +++ b/crates/engine/src/cmd/text/caret.rs @@ -215,18 +215,28 @@ pub(super) fn release(s: &mut Session, p: &Value) -> Result { return ok(); } s.edit(|d, sel| { - let st = d.text_story_mut(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let piece = st.extract(r.clone()); - let mut at = b; - if !copy { - st.delete(r.clone()); - if at > r.start { - at -= r.len().min(at - r.start); + let (piece_len, at, r) = { + let st = d.text_story_mut(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let piece = st.extract(r.clone()); + let mut at = b; + if !copy { + st.delete(r.clone()); + if at > r.start { + at -= r.len().min(at - r.start); + } + } + let end = st.insert_story(at, &piece); + sel.text = Some(TextSel { story: sid, anchor: at, focus: end, frame: Some(frame), cell }); + (piece.text.len(), at, r) + }; + if cell.is_none() { + if copy { + d.adjust_text_ranges(sid, at..at, piece_len); + } else { + d.move_text_ranges(sid, r, at); } } - let end = st.insert_story(at, &piece); - sel.text = Some(TextSel { story: sid, anchor: at, focus: end, frame: Some(frame), cell }); - Ok(json!({"moved": piece.text.len(), "at": at})) + Ok(json!({"moved": piece_len, "at": at})) }) } diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs index c987e3a57..e2e06ea40 100644 --- a/crates/engine/src/cmd/text/edit.rs +++ b/crates/engine/src/cmd/text/edit.rs @@ -38,6 +38,7 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { { r = r.start + 1..r.start + 1; } + let mut auto_edit = None; // Autocorrect: a word ended by a space or punctuation is looked up. if let Some(list) = &autocorrect && r.is_empty() @@ -56,6 +57,7 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { to.clone() }; st.replace(ws..r.start, &to); + auto_edit = Some((ws..r.start, to.len())); r = ws + to.len()..ws + to.len(); } } @@ -83,7 +85,7 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { } if tracking && t.cell.is_none() { // Track Changes: the replaced text is marked deleted, the typing inserted after it. - let at = crate::cmd::changes::mark_deleted(st, r.clone()); + let (at, removed) = crate::cmd::changes::mark_deleted_reporting(st, r.clone()); reserve_command_text(st, text.len())?; let mut fmt = st.insert_format_at(at).clone(); if let Some(f) = &typing { @@ -93,6 +95,11 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { st.insert_with(at, &text, fmt); let pos = at + text.len() - trail; sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + + for rr in removed { + d.adjust_text_ranges(t.story, rr, 0); + } + d.adjust_text_ranges(t.story, at..at, text.len()); return Ok(json!({"pos": pos})); } let a = floor_char_boundary(&st.text, r.start.min(st.len())); @@ -106,6 +113,12 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result { } let pos = r.start + text.len() - trail; sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + if t.cell.is_none() { + if let Some((range, len)) = auto_edit { + d.adjust_text_ranges(t.story, range, len); + } + d.adjust_text_ranges(t.story, a..b, text.len()); + } Ok(json!({"pos": pos})) }); // A preview (IME marked text) leaves the typing format to the text typed in the end. @@ -142,24 +155,46 @@ pub(super) fn delete_surrounding(s: &mut Session, p: &Value) -> Result { }; s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; - let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - if !after_range.is_empty() { - if tracking { - crate::cmd::changes::mark_deleted(story, after_range.clone()); + let (shift, after_removed, before_removed) = { + let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let after_removed = if !after_range.is_empty() { + if tracking { + let (_, removed) = crate::cmd::changes::mark_deleted_reporting(story, after_range.clone()); + removed + } else { + story.delete(after_range.clone()); + Vec::new() + } } else { - story.delete(after_range.clone()); + Vec::new() + }; + let (shift, before_removed) = if !before_range.is_empty() { + if tracking { + // Tracked changes keep the text and mark it deleted: the selection does not move. + let (new_end, removed) = crate::cmd::changes::mark_deleted_reporting(story, before_range.clone()); + (before_range.end.saturating_sub(new_end), removed) + } else { + story.delete(before_range.clone()); + (before_range.len(), Vec::new()) + } + } else { + (0, Vec::new()) + }; + (shift, after_removed, before_removed) + }; + if tracking { + for rr in after_removed { + d.adjust_text_ranges(t.story, rr, 0); } + } else if t.cell.is_none() && !after_range.is_empty() { + d.adjust_text_ranges(t.story, after_range.clone(), 0); } - let mut shift = 0usize; - if !before_range.is_empty() { - if tracking { - // Tracked changes keep the text and mark it deleted: the selection does not move. - let new_end = crate::cmd::changes::mark_deleted(story, before_range.clone()); - shift = before_range.end.saturating_sub(new_end); - } else { - shift = before_range.len(); - story.delete(before_range.clone()); + if tracking { + for rr in before_removed { + d.adjust_text_ranges(t.story, rr, 0); } + } else if t.cell.is_none() && !before_range.is_empty() { + d.adjust_text_ranges(t.story, before_range.clone(), 0); } sel.text = Some(TextSel { anchor: t.anchor.saturating_sub(shift), focus: t.focus.saturating_sub(shift), ..t }); Ok(()) @@ -264,13 +299,24 @@ pub(crate) fn delete_selection(s: &mut Session) -> Result { s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; let r = t.range(); + let mut removed_ranges = Vec::new(); if let Some(st) = d.text_story_mut(t.story, t.cell) { if tracking && t.cell.is_none() { - crate::cmd::changes::mark_deleted(st, r.clone()); + let (_, removed) = crate::cmd::changes::mark_deleted_reporting(st, r.clone()); + removed_ranges = removed; } else { st.delete(r.clone()); } } + if t.cell.is_none() { + if tracking { + for rr in removed_ranges { + d.adjust_text_ranges(t.story, rr, 0); + } + } else { + d.adjust_text_ranges(t.story, r.clone(), 0); + } + } sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); ok() }) @@ -296,12 +342,18 @@ pub(super) fn delete(s: &mut Session, p: &Value) -> Result { let r = with_break_pair(&st.text, r); if tracking && t.cell.is_none() { // Marked, not removed: the caret steps over the deleted text. - let at = crate::cmd::changes::mark_deleted(st, r.clone()); + let (at, removed) = crate::cmd::changes::mark_deleted_reporting(st, r.clone()); let pos = if forward { at.max(r.end.min(st.len())) } else { r.start.min(at) }; sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + for rr in removed { + d.adjust_text_ranges(t.story, rr, 0); + } return ok(); } st.delete(r.clone()); + if t.cell.is_none() { + d.adjust_text_ranges(t.story, r.clone(), 0); + } sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); ok() }) @@ -423,20 +475,36 @@ pub(super) fn change_case(s: &mut Session, p: &Value) -> Result { let targets = format_targets(s); s.edit(|d, sel| { for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - let r = t.range.start.min(st.len())..t.range.end.min(st.len()); - let mapped = case_map(st.slice(r.clone()), &case); - // Replace run by run (from the end) so every run keeps its formatting. - let segs: Vec> = - st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect(); - let starts: Vec = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect(); - let mut delta: isize = 0; - for seg in segs.iter().rev() { - let old = st.slice(seg.clone()).to_string(); - let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect(); - if new != old { - delta += new.len() as isize - old.len() as isize; - st.replace(seg.clone(), &new); + let (delta, r, changes) = { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let r = t.range.start.min(st.len())..t.range.end.min(st.len()); + let mapped = case_map(st.slice(r.clone()), &case); + // Replace run by run (from the end) so every run keeps its formatting. + let segs: Vec> = + st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect(); + let starts: Vec = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect(); + let mut delta: isize = 0; + let mut changes = Vec::new(); + for seg in segs.iter().rev() { + let old = st.slice(seg.clone()).to_string(); + let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect(); + if new != old { + delta += new.len() as isize - old.len() as isize; + st.replace(seg.clone(), &new); + // Per-character changes (back to front), so ranges inside the run keep their boundaries. + for (i, (b, m)) in starts.iter().zip(&mapped).enumerate().rev().filter(|(_, (b, _))| seg.contains(b)) { + let ce = starts.get(i + 1).copied().unwrap_or(r.end); + if m.len() != ce - b { + changes.push((*b..ce, m.len())); + } + } + } + } + (delta, r, changes) + }; + if t.cell.is_none() { + for (range, new_len) in changes { + d.adjust_text_ranges(t.story, range, new_len); } } if let Some(ts) = sel.text.as_mut().filter(|ts| ts.story == t.story && ts.cell == t.cell && !ts.is_caret()) { diff --git a/crates/engine/src/cmd/toc.rs b/crates/engine/src/cmd/toc.rs index b69d17e26..566602d94 100644 --- a/crates/engine/src/cmd/toc.rs +++ b/crates/engine/src/cmd/toc.rs @@ -289,20 +289,23 @@ fn fill(d: &mut Document, toc: &Toc, width: Option) -> Result { } styles.push(e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", f.level.max(1)))); } - let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?; - let len = st.len(); - st.replace(0..len, &text); - // The text takes the format of the old first character: its character style would spread - // over the whole table (a page number before the first entry, without a title). - st.format_chars(0..st.len(), |f| f.style = NO_CHAR_STYLE.into()); - for (r, style) in spans { - st.format_chars(r, |f| f.style.clone_from(&style)); - } - for (pi, r) in st.para_ranges().into_iter().enumerate() { - if let Some(name) = styles.get(pi) { - st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); + { + let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?; + let len = st.len(); + st.replace(0..len, &text); + // The text takes the format of the old first character: its character style would spread + // over the whole table (a page number before the first entry, without a title). + st.format_chars(0..st.len(), |f| f.style = NO_CHAR_STYLE.into()); + for (r, style) in spans { + st.format_chars(r, |f| f.style.clone_from(&style)); + } + for (pi, r) in st.para_ranges().into_iter().enumerate() { + if let Some(name) = styles.get(pi) { + st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() }); + } } } + d.prune_story_text_ranges(toc.story); let insertion = d.bookmarks.iter().position(|b| b.generated_by_toc).unwrap_or(d.bookmarks.len()); d.bookmarks.retain(|b| !b.generated_by_toc); if toc.create_bookmarks { diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 33c3c8821..e2bf23e9a 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -52,7 +52,11 @@ pub fn specs() -> Vec { continue; } let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - unvar(st, i, n, &value, 0); + let mut replacements = Vec::new(); + unvar(st, i, n, &value, 0, &mut |pos, len| replacements.push((pos, len))); + for (pos, tlen) in replacements { + d.adjust_text_ranges(sid, pos..pos + tlen, value.len()); + } } d.text_variables.remove(i); Ok(Value::Null) @@ -352,14 +356,17 @@ fn needs_unvar(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool { /// `i + 1..n` down by one, including table cells and foot/endnotes nested in the story. Only /// touches (and unshares) the parts that hold such instances; a story whose nested text changed /// gets a new revision. -fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth: usize) { +fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth: usize, on_replace: &mut dyn FnMut(usize, usize)) { if depth > MAX_DEPTH { return; } if st.text.chars().any(|c| designcraft_doc::vars::var_index(c).is_some_and(|k| k >= i)) { let target = var_char(i).unwrap_or(' ').to_string(); - while let Some(pos) = st.text.find(&target) { + let mut occurrences: Vec = st.text.match_indices(&target).map(|(pos, _)| pos).collect(); + occurrences.reverse(); + for pos in occurrences { st.replace(pos..pos + target.len(), value); + on_replace(pos, target.len()); } for k in i + 1..n { let (from, to) = (var_char(k).unwrap_or(' ').to_string(), var_char(k - 1).unwrap_or(' ').to_string()); @@ -375,14 +382,14 @@ fn unvar(st: &mut designcraft_doc::Story, i: usize, n: usize, value: &str, depth } for c in &mut std::sync::Arc::make_mut(t).cells { if needs_unvar(&c.text, i, depth + 1) { - unvar(&mut c.text, i, n, value, depth + 1); + unvar(&mut c.text, i, n, value, depth + 1, &mut |_, _| {}); } } nested = true; } for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { if needs_unvar(¬e.text, i, depth + 1) { - unvar(&mut std::sync::Arc::make_mut(note).text, i, n, value, depth + 1); + unvar(&mut std::sync::Arc::make_mut(note).text, i, n, value, depth + 1, &mut |_, _| {}); nested = true; } } diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index 7106b1ed0..68653c105 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -67,7 +67,15 @@ fn destination(d: &mut designcraft_doc::Document, p: &Value) -> Result { } else { return Err(bad("xref.insert", "give `anchor`, `paragraph` or `story` + `para`")); }; - d.paragraph_anchor(sid, pos).ok_or_else(|| bad("xref.insert", "could not anchor the paragraph")) + let before = d.story(sid).map_or(0, |st| st.len()); + let id = d.paragraph_anchor(sid, pos).ok_or_else(|| bad("xref.insert", "could not anchor the paragraph"))?; + let grew = d.story(sid).map_or(0, |st| st.len()).saturating_sub(before); + if grew > 0 + && let Some((asid, apos)) = d.find_anchor(id) + { + d.adjust_text_ranges(asid, apos..apos, grew); + } + Ok(id) } fn insert(s: &mut Session, p: &Value) -> Result { @@ -95,6 +103,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { let r = r.start.min(st.len())..r.end.min(st.len()); st.delete(r.clone()); st.insert_xref(r.start, CrossRef { target, format, ..Default::default() }); + if t.cell.is_none() { + d.adjust_text_ranges(t.story, r.clone(), designcraft_doc::XREF_MARK.len_utf8()); + } let pos = r.start + designcraft_doc::XREF_MARK.len_utf8(); sel.text = Some(TextSel { anchor: pos, focus: pos, ..t2 }); Ok(json!({"target": target, "pos": r.start})) @@ -116,6 +127,9 @@ fn create_anchor(s: &mut Session, p: &Value) -> Result { let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let at = t.range().start.min(st.len()); let id = st.insert_anchor(at, &name); + if t.cell.is_none() { + d.adjust_text_ranges(t.story, at..at, designcraft_doc::ANCHOR_MARK.len_utf8()); + } let pos = t.focus + designcraft_doc::ANCHOR_MARK.len_utf8(); sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); Ok(json!({"id": id})) diff --git a/crates/engine/tests/hyperlink_offsets.rs b/crates/engine/tests/hyperlink_offsets.rs new file mode 100644 index 000000000..1862221e5 --- /dev/null +++ b/crates/engine/tests/hyperlink_offsets.rs @@ -0,0 +1,712 @@ +//! Hyperlink text source range adjustments across text editing (#1219). + +use designcraft_doc::{HyperlinkSource, StoryId}; +use designcraft_engine::Session; +use serde_json::json; + +fn fixture(text: &str) -> (Session, StoryId, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 500, 700], "content": "text", "text": text})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + (s, sid, r["id"].as_u64().unwrap()) +} + +fn text_hyperlink_range(s: &Session, hid: u64) -> (usize, usize) { + let doc = &s.doc().unwrap().doc; + let link = doc.hyperlinks.iter().find(|h| h.id == hid).expect("hyperlink exists"); + match link.source { + HyperlinkSource::Text { start, end, .. } => (start, end), + _ => panic!("expected text hyperlink source"), + } +} + +#[test] +fn typing_before_hyperlink_shifts_source_range() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + // "page 2" is at bytes 17..23 + assert_eq!(&initial[17..23], "page 2"); + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Type "ABC " at the start of the story (offset 0) + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": "ABC ", "raw": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "ABC Hello world. See page 2 for details."); + + // The hyperlink must now be at bytes 21..27, covering "page 2" + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (21, 27)); + assert_eq!(&text[start..end], "page 2"); +} + +#[test] +fn multibyte_utf8_inserted_before_hyperlink_shifts_by_byte_length() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + + // Insert soft hyphen U+00AD (2 UTF-8 bytes) at offset 0 + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{00AD}", "raw": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (19, 25)); + assert_eq!(&text[start..end], "page 2"); + + // Insert German umlaut word "Größe " (8 UTF-8 bytes: G, r, ö (2B), ß (2B), e, space) at offset 0 + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": "Größe ", "raw": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (27, 33)); + assert_eq!(&text[start..end], "page 2"); +} + +#[test] +fn typing_inside_hyperlink_expands_source_range() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + + // Type "XYZ " inside the link between "page " and "2" (offset 22) + s.execute("text.select", &json!({"story": sid.0, "anchor": 22, "focus": 22})).unwrap(); + s.execute("text.insert", &json!({"text": "XYZ ", "raw": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello world. See page XYZ 2 for details."); + + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (17, 27)); + assert_eq!(&text[start..end], "page XYZ 2"); +} + +#[test] +fn deleting_before_and_across_hyperlink() { + let initial = "ABC Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + // "page 2" is at bytes 21..27 + assert_eq!(&initial[21..27], "page 2"); + s.execute("text.select", &json!({"story": sid.0, "anchor": 21, "focus": 27})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + + // Delete "ABC " (4 bytes) before the link + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello world. See page 2 for details."); + + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (17, 23)); + assert_eq!(&text[start..end], "page 2"); + + // Delete across the link: from offset 13 ("See ") through 22 ("page") + s.execute("text.select", &json!({"story": sid.0, "anchor": 13, "focus": 22})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello world. 2 for details."); + + // "2" survived the deletion, starting at offset 13 + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (13, 14)); + assert_eq!(&text[start..end], "2"); +} + +#[test] +fn deleting_entire_hyperlink_removes_it() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + + // Select text encompassing the entire link (13..27: "See page 2 for") and delete + s.execute("text.select", &json!({"story": sid.0, "anchor": 13, "focus": 27})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + + let doc = &s.doc().unwrap().doc; + assert!(!doc.hyperlinks.iter().any(|h| h.id == hid), "hyperlink should be pruned when text is deleted"); +} + +#[test] +fn replacing_hyperlink_text_updates_source_range() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + + // Select "page 2" and replace with "chapter 5" (9 bytes) + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + s.execute("text.insert", &json!({"text": "chapter 5", "raw": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello world. See chapter 5 for details."); + + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (17, 26)); + assert_eq!(&text[start..end], "chapter 5"); +} + +#[test] +fn undo_and_redo_restore_hyperlink_ranges() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Type "ABC " at 0 + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": "ABC ", "raw": true})).unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (21, 27)); + + // Undo + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Redo + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (21, 27)); +} + +#[test] +fn track_changes_deleting_inserted_text_reconciles_hyperlink_ranges() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Enable Track Changes + s.execute("changes.track", &json!({"on": true})).unwrap(); + + // Type "Draft " (6 bytes) at offset 0 while tracking + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": "Draft ", "raw": true})).unwrap(); + + // Hyperlink shifts to 23..29 + assert_eq!(text_hyperlink_range(&s, hid), (23, 29)); + + // Now select and delete the inserted "Draft " (0..6) while tracking is on + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 6})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + + // The inserted text was physically removed, so the hyperlink must reconcile back to 17..23 + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello world. See page 2 for details."); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); +} + +#[test] +fn change_case_adjusts_hyperlink_offsets_when_text_length_changes() { + let initial = "Look at \u{FB01} and see page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + // "\u{FB01}" (fi ligature) is at bytes 8..11 (3 bytes). + // "page 2" is at bytes 20..26. + s.execute("text.select", &json!({"story": sid.0, "anchor": 20, "focus": 26})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (20, 26)); + + // Select "\u{FB01}" (8..11) and convert to uppercase ("FI", 2 bytes) + s.execute("text.select", &json!({"story": sid.0, "anchor": 8, "focus": 11})).unwrap(); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Look at FI and see page 2 for details."); + + // Hyperlink should shift backward by 1 byte: 19..25 + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (19, 25)); + assert_eq!(&text[start..end], "page 2"); +} + +#[test] +fn change_case_preserves_hyperlink_strictly_inside_selection() { + let initial = "Hello world"; + let (mut s, sid, _) = fixture(initial); + + // Hyperlink on "world" (6..11) + s.execute("text.select", &json!({"story": sid.0, "anchor": 6, "focus": 11})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com/world"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (6, 11)); + + // Select entire line (0..11) containing the hyperlink inside it and uppercase + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "HELLO WORLD"); + + // Hyperlink must remain intact at 6..11 + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (6, 11)); + assert_eq!(&text[start..end], "WORLD"); +} + +#[test] +fn formatted_paste_adjusts_hyperlinks_and_placeholders() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Select "Hello " (0..6) and copy it + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 6})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + + // Move caret to start and paste formatted + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.paste", &json!({"formatted": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello Hello world. See page 2 for details."); + + // Hyperlink shifts forward by 6 bytes: 23..29 + let (start, end) = text_hyperlink_range(&s, hid); + assert_eq!((start, end), (23, 29)); + assert_eq!(&text[start..end], "page 2"); +} + +#[test] +fn drag_move_preserves_hyperlink_and_adjusts_intervening_ranges() { + let initial = "Hello brave world."; + let (mut s, sid, fid) = fixture(initial); + + // Hyperlink on "brave" (6..11) + s.execute("text.select", &json!({"story": sid.0, "anchor": 6, "focus": 11})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com/brave"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (6, 11)); + + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + let l = &cs.frames[0].lines[0]; + let g = l.glyphs.iter().find(|g| g.byte == b).unwrap(); + json!([g.x + g.adv * 0.25, l.baseline - 3.0]) + }; + let end = |s: &Session| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + let l = &cs.frames[0].lines[0]; + json!([l.end_x + 2.0, l.baseline - 3.0]) + }; + + // Drag "brave " (6..12) to the end of the line + s.execute("text.select", &json!({"story": sid.0, "anchor": 6, "focus": 12})).unwrap(); + s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 8)})).unwrap(); + s.execute("text.release", &json!({"frame": fid, "point": end(&s), "moved": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello world.brave "); + + // The hyperlink on "brave" must follow the moved text to 12..17 + let (start, end_pos) = text_hyperlink_range(&s, hid); + assert_eq!((start, end_pos), (12, 17)); + assert_eq!(&text[start..end_pos], "brave"); +} + +#[test] +fn typing_at_end_of_hyperlink_does_not_expand_range() { + let initial = "Click here for details."; + let (mut s, sid, _) = fixture(initial); + + // "here" is at bytes 6..10 + s.execute("text.select", &json!({"story": sid.0, "anchor": 6, "focus": 10})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (6, 10)); + + // Place caret at the very end of "here" (offset 10) and type + s.execute("text.select", &json!({"story": sid.0, "anchor": 10, "focus": 10})).unwrap(); + s.execute("text.insert", &json!({"text": " right now", "raw": true})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Click here right now for details."); + // The hyperlink on "here" must NOT expand; it must stay 6..10 + assert_eq!(text_hyperlink_range(&s, hid), (6, 10)); + assert_eq!(&text[6..10], "here"); +} + +#[test] +fn track_changes_accept_all_shifts_later_hyperlinks() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + // "page 2" at 17..23 + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Track Changes on + s.execute("changes.track", &json!({"on": true})).unwrap(); + + // Delete "Hello " (0..6) while tracking + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 6})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + + // While tracked change is pending, "Hello " is marked deleted but still in text + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Accept All + s.execute("changes.acceptAll", &json!({})).unwrap(); + + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "world. See page 2 for details."); + // Link must shift by 6 bytes to 11..17 + assert_eq!(text_hyperlink_range(&s, hid), (11, 17)); + assert_eq!(&text[11..17], "page 2"); +} + +#[test] +fn track_changes_replacing_hyperlink_text_leaves_link_on_deleted_span_until_resolved() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + // "page 2" at 17..23 + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Track Changes on + s.execute("changes.track", &json!({"on": true})).unwrap(); + + // Select "page 2" (17..23) and replace with "p. 2" (length 4) + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + s.execute("text.insert", &json!({"text": "p. 2", "raw": true})).unwrap(); + + // While pending, "page 2" remains in text (marked Deleted). Link stays on 17..23. + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Reject All restores original text and keeps the link intact. + s.execute("changes.rejectAll", &json!({})).unwrap(); + let text = &s.doc().unwrap().doc.story(sid).unwrap().text; + assert_eq!(text, "Hello world. See page 2 for details."); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); +} + +#[test] +fn track_changes_accepting_deletion_of_hyperlink_text_prunes_link() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + // "page 2" at 17..23 + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Track Changes on + s.execute("changes.track", &json!({"on": true})).unwrap(); + + // Select "page 2" (17..23) and replace with "p. 2" (length 4) + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + s.execute("text.insert", &json!({"text": "p. 2", "raw": true})).unwrap(); + + // Accepting all removes the deleted text, pruning the link + s.execute("changes.acceptAll", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.story(sid).unwrap().text, "Hello world. See p. 2 for details."); + assert!(doc.hyperlinks.iter().all(|h| h.id != hid)); +} + +#[test] +fn inline_markers_shift_subsequent_hyperlinks() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + // "page 2" at 17..23 + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // 1. Create a text anchor at offset 5 ("anchor.create") + s.execute("text.select", &json!({"story": sid.0, "anchor": 5, "focus": 5})).unwrap(); + let aid = s.execute("anchor.create", &json!({"name": "TargetAnchor"})).unwrap()["id"].as_u64().unwrap(); + let anchor_shift = designcraft_doc::ANCHOR_MARK.len_utf8(); + assert_eq!(text_hyperlink_range(&s, hid), (17 + anchor_shift, 23 + anchor_shift)); + + // 2. Insert a cross-reference at offset 0 ("xref.insert") + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("xref.insert", &json!({"anchor": aid})).unwrap(); + let xref_shift = designcraft_doc::XREF_MARK.len_utf8(); + assert_eq!(text_hyperlink_range(&s, hid), (17 + anchor_shift + xref_shift, 23 + anchor_shift + xref_shift)); + + // 3. Insert an editorial note at offset 2 ("note.new") + s.execute("text.select", &json!({"story": sid.0, "anchor": 2, "focus": 2})).unwrap(); + let nid = s.execute("note.new", &json!({"text": "review this"})).unwrap()["id"].as_u64().unwrap(); + let note_shift = designcraft_doc::NOTE_MARK.len_utf8(); + assert_eq!(text_hyperlink_range(&s, hid), (17 + anchor_shift + xref_shift + note_shift, 23 + anchor_shift + xref_shift + note_shift)); + + // 4. Delete the editorial note ("note.delete") + s.execute("note.delete", &json!({"story": sid.0, "id": nid})).unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17 + anchor_shift + xref_shift, 23 + anchor_shift + xref_shift)); +} + +#[test] +fn zero_length_placeholder_at_caret_is_preserved_on_story_edits() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("data.source.select", &json!({"bytes": "TmFtZQpBZGEK", "name": "people.csv"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 6, "focus": 6})).unwrap(); + let ph_id = s.execute("data.placeholder.add", &json!({"field": "Name", "role": "hyperlink"})).unwrap()["id"].as_u64().unwrap(); + + let doc = &s.doc().unwrap().doc; + let ph = doc.data_merge.placeholders.iter().find(|p| p.id == ph_id).unwrap(); + assert_eq!(ph.anchor, designcraft_doc::PlaceholderAnchor::Text { story: sid, start: 6, end: 6 }); + + // Type text at offset 0 + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("text.insert", &json!({"text": "Prefix: ", "raw": true})).unwrap(); + + // Placeholder must shift forward and NOT be pruned + let doc = &s.doc().unwrap().doc; + let ph = doc.data_merge.placeholders.iter().find(|p| p.id == ph_id).expect("placeholder must survive edit"); + assert_eq!(ph.anchor, designcraft_doc::PlaceholderAnchor::Text { story: sid, start: 14, end: 14 }); +} + +#[test] +fn find_change_in_table_cell_does_not_shift_story_hyperlinks() { + let initial = "Before table. "; + let (mut s, sid, _) = fixture(initial); + + // Insert a table with 1 row and 1 col, typing "apple" into the cell + s.execute("text.select", &json!({"story": sid.0, "anchor": 14, "focus": 14})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": "apple", "raw": true})).unwrap(); + + // Add more text and a hyperlink to the parent story + s.execute("text.select", &json!({"story": sid.0, "anchor": 15, "focus": 15})).unwrap(); + s.execute("text.insert", &json!({"text": "After table link text.", "raw": true})).unwrap(); + + // Link covers "link" in parent story + let st_text = &s.doc().unwrap().doc.story(sid).unwrap().text; + let link_start = st_text.find("link").unwrap(); + let link_end = link_start + 4; + s.execute("text.select", &json!({"story": sid.0, "anchor": link_start, "focus": link_end})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (link_start, link_end)); + + // Run find.change on document scope to replace "apple" with "longer replacement" in the cell + let r = s.execute("find.change", &json!({"find": "apple", "change": "longer replacement", "scope": "document"})).unwrap(); + assert_eq!(r["count"], 1); + + // The cell was changed, but the parent story hyperlink offsets MUST remain intact + assert_eq!(text_hyperlink_range(&s, hid), (link_start, link_end)); +} + +#[test] +fn xref_insert_paragraph_target_shifts_subsequent_hyperlinks() { + let initial = "First target paragraph.\nSecond paragraph with a link here."; + let (mut s, sid, _) = fixture(initial); + + // "link" in second paragraph + let link_start = initial.find("link").unwrap(); + let link_end = link_start + 4; + s.execute("text.select", &json!({"story": sid.0, "anchor": link_start, "focus": link_end})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (link_start, link_end)); + + // Insert cross reference at the end of the second paragraph targeting the first paragraph + s.execute("text.select", &json!({"story": sid.0, "anchor": initial.len(), "focus": initial.len()})).unwrap(); + s.execute("xref.insert", &json!({"paragraph": "First target"})).unwrap(); + + // The first paragraph now has an ANCHOR_MARK inserted at offset 0. + // The hyperlink in the second paragraph is after offset 0, so it must shift by ANCHOR_MARK.len_utf8()! + let anchor_shift = designcraft_doc::ANCHOR_MARK.len_utf8(); + assert_eq!(text_hyperlink_range(&s, hid), (link_start + anchor_shift, link_end + anchor_shift)); +} + +#[test] +fn tracked_replacement_pruning_selected_entry_does_not_corrupt_unrelated_entry() { + let initial = "Initial text. "; + let (mut s, sid, _) = fixture(initial); + + // Track Changes on + s.execute("changes.track", &json!({"on": true})).unwrap(); + + // Type text under track changes at offset 14: "Tracked link. Unrelated link." + s.execute("text.select", &json!({"story": sid.0, "anchor": 14, "focus": 14})).unwrap(); + s.execute("text.insert", &json!({"text": "Tracked link. Unrelated link.", "raw": true})).unwrap(); + + // "Tracked link" is at 14..26 (marked Inserted). Add a hyperlink on it. + s.execute("text.select", &json!({"story": sid.0, "anchor": 14, "focus": 26})).unwrap(); + let h1 = s.execute("hyperlink.create", &json!({"url": "https://example.com/tracked"})).unwrap()["ids"][0].as_u64().unwrap(); + + // "Unrelated link" is at 28..42. Add a hyperlink on it. + s.execute("text.select", &json!({"story": sid.0, "anchor": 28, "focus": 42})).unwrap(); + let h2 = s.execute("hyperlink.create", &json!({"url": "https://example.com/unrelated"})).unwrap()["ids"][0].as_u64().unwrap(); + + assert_eq!(text_hyperlink_range(&s, h1), (14, 26)); + assert_eq!(text_hyperlink_range(&s, h2), (28, 42)); + + // Now select "Tracked link." (14..27) which has ChangeMark::Inserted and replace it with "New." + s.execute("text.select", &json!({"story": sid.0, "anchor": 14, "focus": 27})).unwrap(); + s.execute("text.insert", &json!({"text": "New.", "raw": true})).unwrap(); + + // The old tracked text (14..27) was physically removed by mark_deleted_reporting. + // h1 covered (14..26) inside that removed range, and was pruned. + // h2 must NOT have been corrupted by taking on the insertion span! + // It should simply have shifted with the edit (14..27 removed, 4 bytes inserted -> net delta -9). + assert_eq!(text_hyperlink_range(&s, h2), (19, 33)); + let doc = &s.doc().unwrap().doc; + let st_text = &doc.story(sid).unwrap().text; + assert_eq!(&st_text[19..33], "Unrelated link"); +} + +#[test] +fn place_text_at_caret_shifts_subsequent_hyperlinks_by_actual_inserted_length() { + let initial = "Hello world. See link here."; + let (mut s, sid, _) = fixture(initial); + + let link_start = initial.find("link").unwrap(); + let link_end = link_start + 4; + s.execute("text.select", &json!({"story": sid.0, "anchor": link_start, "focus": link_end})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (link_start, link_end)); + + // Place caret at offset 6 ("world.") + s.execute("text.select", &json!({"story": sid.0, "anchor": 6, "focus": 6})).unwrap(); + // Base64 for "amazing new " (12 bytes) + let b64 = "YW1hemluZyBuZXcg"; + s.execute("file.place", &json!({"base64": b64, "name": "insert.txt"})).unwrap(); + + let doc = &s.doc().unwrap().doc; + let text = &doc.story(sid).unwrap().text; + assert!(text.contains("amazing new ")); + // Subsequent hyperlink must shift by 12 bytes + assert_eq!(text_hyperlink_range(&s, hid), (link_start + 12, link_end + 12)); +} + +#[test] +fn wholesale_story_replacement_prunes_links_and_undo_restores_them() { + let initial = "Hello world. See page 2 for details."; + let (mut s, sid, _) = fixture(initial); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 17, "focus": 23})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); + + // Replace the entire story text via story.setText + s.execute("story.setText", &json!({"story": sid.0, "text": "Brand new story content entirely."})).unwrap(); + + // Hyperlink in sid must be pruned + assert!(s.doc().unwrap().doc.hyperlinks.iter().all(|h| h.id != hid)); + + // Undo restores the document snapshot including the hyperlink + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (17, 23)); +} + +#[test] +fn delete_surrounding_adjusts_hyperlink_ranges() { + let initial = "Prefix Keep Caret Suffix LinkText"; + let (mut s, sid, _) = fixture(initial); + + let link_start = initial.find("LinkText").unwrap(); + let link_end = link_start + "LinkText".len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": link_start, "focus": link_end})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert_eq!(text_hyperlink_range(&s, hid), (link_start, link_end)); + + // Place caret between "Keep" and " Caret" (offset 11) + let caret = initial.find(" Caret").unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": caret, "focus": caret})).unwrap(); + + // Delete 4 chars before ("Keep") and 6 chars after (" Caret") + s.execute("text.deleteSurrounding", &json!({"before": 4, "after": 6})).unwrap(); + + let doc = &s.doc().unwrap().doc; + let text = &doc.story(sid).unwrap().text; + assert_eq!(text, "Prefix Suffix LinkText"); + // Link must shift down by 10 bytes (4 before + 6 after) + assert_eq!(text_hyperlink_range(&s, hid), (link_start - 10, link_end - 10)); + assert_eq!(&text[link_start - 10..link_end - 10], "LinkText"); +} + +#[test] +fn regenerating_toc_prunes_story_hyperlinks() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let pages = s.doc().unwrap().doc.page_count(); + s.execute("layout.pages.insert", &json!({"count": 1})).unwrap(); + let r = s.execute("toc.generate", &json!({"styles": ["Headline", "Cover Title"], "page": pages + 1})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 8})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert!(s.doc().unwrap().doc.hyperlinks.iter().any(|h| h.id == hid)); + + let d = &s.doc().unwrap().doc; + let hid_heading = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap(); + s.execute("story.replaceRange", &json!({"story": hid_heading.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap(); + s.execute("toc.update", &json!({})).unwrap(); + + assert!(s.doc().unwrap().doc.hyperlinks.iter().all(|h| h.id != hid)); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn regenerating_index_prunes_story_hyperlinks() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let sp = s.doc().unwrap().doc.page_loc(0).unwrap().0; + let r = + s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 300], "content": "text", "text": "Typography topic here."})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("index.addReference", &json!({})).unwrap(); + + let r_idx = s.execute("index.generate", &json!({"page": 2})).unwrap(); + let idx_sid = StoryId(r_idx["story"].as_u64().unwrap()); + + s.execute("text.select", &json!({"story": idx_sid.0, "anchor": 0, "focus": 5})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert!(s.doc().unwrap().doc.hyperlinks.iter().any(|h| h.id == hid)); + + s.execute("index.update", &json!({})).unwrap(); + + assert!(s.doc().unwrap().doc.hyperlinks.iter().all(|h| h.id != hid)); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn refreshing_live_caption_prunes_story_hyperlinks() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.altText", &json!({"text": "Initial photo caption", "ids": [a]})).unwrap(); + let r = s.execute("object.caption", &json!({"ids": [a], "text": "Photo: {altText}", "live": true})).unwrap(); + let cid = designcraft_doc::ItemId(r["ids"][0].as_u64().unwrap()); + let sid = s.doc().unwrap().doc.item(cid).unwrap().text_frame().unwrap().story; + + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap(); + let hid = s.execute("hyperlink.create", &json!({"url": "https://example.com"})).unwrap()["ids"][0].as_u64().unwrap(); + assert!(s.doc().unwrap().doc.hyperlinks.iter().any(|h| h.id == hid)); + + s.execute("object.altText", &json!({"text": "Updated photo caption", "ids": [a]})).unwrap(); + + assert!(s.doc().unwrap().doc.hyperlinks.iter().all(|h| h.id != hid)); + s.doc().unwrap().doc.check().unwrap(); +}