mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-11 02:50:22 -06:00
* 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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}))
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()}))
|
||||
})
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
|
||||
@@ -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}))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¬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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user