Text: adjust hyperlink and placeholder source ranges on story edits (#1219) (#1229)

* Text: adjust hyperlink and placeholder source ranges on story edits (#1219)

Text-anchored hyperlinks and data-merge placeholders store byte ranges
(`start..end`) pointing into story text. When text earlier in the story is
inserted or deleted, these offsets remained unchanged, causing hyperlink
targets and data-merge placeholders to drift across unrelated text or
point to invalid UTF-8 boundaries.

This change:
- Implements `Document::adjust_text_ranges` in `designcraft-doc` to shift
  or resize text hyperlinks and placeholders when story text is edited.
  Pure insertions at or before the link shift it forward; insertions
  inside expand the link's span; deletions before shift it back; deletions
  consuming or overlapping the link clamp to surviving text or prune it if
  entirely consumed.
- Cleans up obsolete hyperlinks and placeholders in `set_story_text`.
- Wires range adjustment into engine text commands: typing, deleting,
  selection replacement, autocorrect, `story.replaceRange`, find/change,
  and spelling change.
- Adds comprehensive unit tests in `designcraft-doc` and 7 integration
  regression tests in `crates/engine/tests/hyperlink_offsets.rs`.

* fix(engine): adjust hyperlink and placeholder ranges for physically removed text under track changes

* fix(engine): adjust text ranges for formatted paste, case change, and drag move

* fix(engine): record change case adjustments per character to preserve enclosed hyperlinks

* fix(engine): adjust text ranges for track changes, inline markers, and preserve zero-length ranges

- Route Track Changes accept/reject deletions through adjust_text_ranges, and transfer replaced text hyperlinks to inserted text so links survive accept.
- Hook inline marker operations to adjust_text_ranges (notes, endnotes, xref, anchored objects, index markers, tables, editorial notes, unvar, and place text).
- Replace datamerge::shift_after with adjust_text_ranges.
- Only prune ranges that the edit emptied (s < e && s_new >= e_new), preserving zero-length ranges.
- Drop inverted ranges first and use saturating arithmetic in remap_range.
- Preserve doc.hyperlinks and placeholders ordering in move_text_ranges by mutating in place.
- Remove dead wrapper mark_deleted and call mark_deleted_reporting.
- Fix changes.track invocation parameter to 'on'.
- Add comprehensive regression tests in doc and engine covering all review items.

* fix(text): guard cell find/change, pre-record tracked replaces, shift para xref anchors

* fix(text): record stable IDs for tracked replacement redirects

* fix(text): reconcile range adjustments for review feedback

* fix(text): adjust ranges on delete surrounding and clean up dead auto edit

* Prune text ranges on TOC index and live caption regeneration

* test(engine): format regenerating_index_prunes_story_hyperlinks line wrap
This commit is contained in:
Kushagra Kumar
2026-11-07 19:54:00 -06:00
committed by GitHub
parent 1da6effdeb
commit f9cfd99adc
24 changed files with 1368 additions and 194 deletions
+112
View File
@@ -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<usize>, 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<usize>, 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<usize>, 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
+179
View File
@@ -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.
+5 -1
View File
@@ -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<Item>, position: &AnchorPosition) -> Result<usize> {
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<Value> {
}
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}))
})
+1
View File
@@ -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
}
+55 -31
View File
@@ -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>) -> 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>) -> (usize, Vec<std::ops::Range<usize>>) {
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<std::ops::Range<usize>> = st
@@ -20,6 +20,7 @@ pub(crate) fn mark_deleted(st: &mut Story, r: std::ops::Range<usize>) -> 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>) -> 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>) -> 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<std::ops::Range<usize>> {
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<Value> {
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<Value> {
let ids: Vec<StoryId> = 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
+4 -29
View File
@@ -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<Value> {
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) {
+15 -6
View File
@@ -101,7 +101,9 @@ fn paste_text(s: &mut Session, text: Option<String>, 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<designcraft_doc::Item> = list
@@ -125,12 +127,19 @@ fn paste_text(s: &mut Session, text: Option<String>, 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}))
});
+38 -28
View File
@@ -20,16 +20,19 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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<Value> {
}
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);
+5 -2
View File
@@ -40,8 +40,10 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Value> {
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);
+7 -1
View File
@@ -659,7 +659,7 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
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<usize>, String)> = match (&found, &found_out) {
let mut edits: Vec<(std::ops::Range<usize>, 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<Value> {
.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<Value> {
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()
+12 -6
View File
@@ -88,6 +88,9 @@ fn add_reference(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>().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()}))
})
+1
View File
@@ -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(())
}
+3 -1
View File
@@ -85,9 +85,11 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
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}));
+5 -2
View File
@@ -67,6 +67,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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));
+3
View File
@@ -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));
+7 -2
View File
@@ -73,8 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
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)
})
}),
+28 -16
View File
@@ -67,8 +67,11 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
pub(super) fn delete_table(s: &mut Session, p: &Value) -> Result<Value> {
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()
})
}
+15 -10
View File
@@ -175,17 +175,22 @@ pub fn specs() -> Vec<CommandSpec> {
};
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}))
})
+20 -10
View File
@@ -215,18 +215,28 @@ pub(super) fn release(s: &mut Session, p: &Value) -> Result<Value> {
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}))
})
}
+99 -31
View File
@@ -38,6 +38,7 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
{
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<Value> {
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<Value> {
}
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<Value> {
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<Value> {
}
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<Value> {
};
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<Value> {
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<Value> {
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<Value> {
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<std::ops::Range<usize>> =
st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect();
let starts: Vec<usize> = 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<std::ops::Range<usize>> =
st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect();
let starts: Vec<usize> = 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()) {
+15 -12
View File
@@ -289,20 +289,23 @@ fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> {
}
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 {
+12 -5
View File
@@ -52,7 +52,11 @@ pub fn specs() -> Vec<CommandSpec> {
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<usize> = 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(&note.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;
}
}
+15 -1
View File
@@ -67,7 +67,15 @@ fn destination(d: &mut designcraft_doc::Document, p: &Value) -> Result<u64> {
} 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<Value> {
@@ -95,6 +103,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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}))
+712
View File
@@ -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();
}