Browse / github.com/storytold/designcraft / patch
Copy: copied text gets text anchors and tables of its own
Copy: copied text gets text anchors and tables of its own Edit › Duplicate, Paste and Duplicate Spread copy a text frame's story through `object::renumber`, which gave the copy a new story id but kept its text anchor ids and table ids. The copy and the original shared them, so a cross-reference in the copy resolved to the original's paragraph (`Document::find_anchor` looks in the story the id was made in first), and `table.*` commands given `{table}` alone changed whichever story `find_table` found first. A cross-reference inserts a text anchor at the start of its destination paragraph ([Cross-references](https://web.archive.org/web/20250101165015/ht
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #1843, open)
- Base
18246dddd2c9af3bd062b0e69781a040b34ff9b2- Size
- +272 −9 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b24ab25bec643ce32dd0b149
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/edit.rs crates/engine/src/cmd/object.rs
@@ -91,15 +91,17 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result< let mut stories = std::collections::HashMap::new(); let fresh: Vec<designcraft_doc::Item> = list .iter()- .map(|i| {- let mut i = i.clone();- super::object::renumber(d, &items, &mut i, &mut stories);+ .map(|orig| {+ let mut i = orig.clone();+ if !super::object::renumber(d, &items, &mut i, &mut stories) {+ return Err(designcraft_doc::DocError::Invalid(format!("{} nests groups too deeply to copy", orig.id)));+ } if d.layer(i.layer).is_none() { i.layer = d.default_layer(); }- i+ Ok(i) })- .collect();+ .collect::<std::result::Result<_, _>>()?; let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?; sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); Ok(json!({"pos": end, "anchored": list.len()}))@@ -1056,7 +1056,9 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp let Some(it) = src.item(*id) else { continue }; let mut copy = it.clone(); copy.xf = Affine::translate(off) * copy.xf;- renumber(dst, src, &mut copy, &mut story_map);+ if !renumber(dst, src, &mut copy, &mut story_map) {+ return Err(designcraft_doc::DocError::Invalid(format!("{id} nests groups too deeply to copy")).into());+ } if dst.layer(copy.layer).is_none() { copy.layer = dst.default_layer(); }@@ -1067,6 +1069,9 @@ pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: Sp Ok(out) } +/// Groups nesting deeper than this are not copied: [`renumber`] reports them.+const MAX_COPY_DEPTH: usize = 64;+ /// The id to reference in `dst` for asset `id` of `src`. When `src` lacks the asset (a missing graphic), the reference /// gets a fresh id from `dst`, so it stays missing: `src`'s id may already name, or later be allocated to, another /// image in `dst`.@@ -1074,8 +1079,15 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId { dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc())) } -/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses.-pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {+/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses. `stories` maps+/// the stories of `src` copied so far to their copies. False when its groups nest deeper than [`MAX_COPY_DEPTH`]: the+/// items below that keep their ids, and the copy must not be used.+pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) -> bool {+ renumber_in(dst, src, it, stories, 0)+}++/// [`renumber`] for an item `depth` groups deep.+fn renumber_in(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>, depth: usize) -> bool { it.id = ItemId(dst.alloc()); let my_id = it.id; if let Some(m) = it.media.as_mut()@@ -1091,6 +1103,7 @@ pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, storie let n = StoryId(dst.alloc()); let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); st.id = n;+ renumber_marks(dst, src, stories, &mut st); let mut assets = HashMap::new(); for a in designcraft_doc::story_asset_refs(&st) { let na = adopt_or_dangle(dst, src, a);@@ -1114,12 +1127,99 @@ pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, storie g.asset = adopt_or_dangle(dst, src, g.asset); } Content::Group { items } => {+ if depth >= MAX_COPY_DEPTH {+ return false;+ }+ let mut complete = true; for c in items.iter_mut() {- renumber(dst, src, Arc::make_mut(c), stories);+ complete &= renumber_in(dst, src, Arc::make_mut(c), stories, depth + 1); }+ return complete; } Content::Unassigned => {} }+ true+}++/// Give the text anchors and tables of `st`, a new copy of a story of `src`, ids of their own (anchors from the story's+/// id, tables from `dst`). Cross-references in `st` and in the copies `stories` already lists point at the copies of+/// their destinations where those are copied, and the copy's paragraphs follow its tables.+fn renumber_marks(dst: &mut Document, src: &Document, stories: &HashMap<StoryId, StoryId>, st: &mut Story) {+ let base = st.id.0 << 24;+ let mut anchors = HashMap::new();+ for (k, a) in (1..).zip(st.anchors.iter_mut()) {+ anchors.insert(a.id, base + k);+ Arc::make_mut(a).id = base + k;+ }+ for (old, new) in stories {+ if let (Some(o), Some(n)) = (src.story(*old), dst.story(*new)) {+ anchors.extend(o.anchors.iter().zip(&n.anchors).map(|(a, b)| (a.id, b.id)));+ }+ }+ // An earlier copy's targets are found again from its original: a new anchor id can be another original's.+ for (old, new) in stories {+ if let (Some(o), Some(copy)) = (src.story(*old), dst.story_mut(*new)) {+ retarget_xrefs(copy, o, &anchors, 0);+ }+ }+ remap_xrefs(st, &anchors, 0);+ let mut tables = HashMap::new();+ for (old, mut table) in std::mem::take(&mut st.tables) {+ let id = dst.alloc();+ Arc::make_mut(&mut table).id = id;+ tables.insert(old, id);+ st.tables.insert(id, table);+ }+ for pf in &mut st.paras {+ pf.table = pf.table.map(|t| tables.get(&t).copied().unwrap_or(t));+ }+}++/// Point the cross-references in `st`, and in the stories of its table cells and notes down to [`MAX_COPY_DEPTH`]+/// levels, at the anchors `map` gives for their targets.+fn remap_xrefs(st: &mut Story, map: &HashMap<u64, u64>, depth: usize) {+ if map.is_empty() {+ return;+ }+ for x in &mut st.xrefs {+ if let Some(n) = map.get(&x.target) {+ Arc::make_mut(x).target = *n;+ }+ }+ if depth >= MAX_COPY_DEPTH {+ return;+ }+ for t in st.tables.values_mut() {+ for c in &mut Arc::make_mut(t).cells {+ remap_xrefs(&mut c.text, map, depth + 1);+ }+ }+ for n in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {+ remap_xrefs(&mut Arc::make_mut(n).text, map, depth + 1);+ }+}++/// Point the cross-references in `copy`, a copy of `orig`, and in the stories of its table cells and notes down to+/// [`MAX_COPY_DEPTH`] levels, at the anchors `map` gives for the targets of the matching references in `orig`.+fn retarget_xrefs(copy: &mut Story, orig: &Story, map: &HashMap<u64, u64>, depth: usize) {+ for (x, o) in copy.xrefs.iter_mut().zip(&orig.xrefs) {+ let target = map.get(&o.target).copied().unwrap_or(o.target);+ if x.target != target {+ Arc::make_mut(x).target = target;+ }+ }+ if depth >= MAX_COPY_DEPTH {+ return;+ }+ // Copied tables have new ids, given in the order of the old ones, so the two maps list them in the same order.+ for (t, o) in copy.tables.values_mut().zip(orig.tables.values()) {+ for (c, oc) in Arc::make_mut(t).cells.iter_mut().zip(&o.cells) {+ retarget_xrefs(&mut c.text, &oc.text, map, depth + 1);+ }+ }+ for (n, o) in copy.notes.iter_mut().zip(&orig.notes).chain(copy.endnotes.iter_mut().zip(&orig.endnotes)) {+ retarget_xrefs(&mut Arc::make_mut(n).text, &o.text, map, depth + 1);+ } } fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {@@ -5078,6 +5178,167 @@ mod copy_ids_tests { assert_eq!((r["created"].as_u64(), ids(&r).len()), (Some(3), 3), "{r}"); assert!(ids(&r).iter().all(|i| s.doc().unwrap().doc.item(designcraft_doc::ItemId(*i)).is_some())); }++ /// A selected text frame whose story holds a paragraph anchor, a cross-reference to it and a table.+ fn marked_frame(s: &mut crate::Session) -> (u64, designcraft_doc::StoryId) {+ s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();+ let text = json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Results\nSee "});+ let f = s.execute("frame.create", &text).unwrap()["id"].as_u64().unwrap();+ let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().text_frame().unwrap().story;+ s.execute("text.select", &json!({"story": sid.0, "anchor": 12, "focus": 12})).unwrap();+ s.execute("xref.insert", &json!({"paragraph": "Results"})).unwrap();+ s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();+ s.execute("selection.set", &json!({"ids": [f]})).unwrap();+ (f, sid)+ }++ /// The copy of story `orig` made for item `copy` has a text anchor and a table of its own, and its cross-reference+ /// points at its own anchor. A table command naming the copy's table changes the copy alone.+ fn assert_own_marks(s: &mut crate::Session, orig: designcraft_doc::StoryId, copy: u64) {+ let d = s.doc().unwrap().doc.clone();+ let sid = d.item(designcraft_doc::ItemId(copy)).unwrap().text_frame().unwrap().story;+ assert_ne!(sid, orig);+ let (o, c) = (d.story(orig).unwrap(), d.story(sid).unwrap());+ let (oa, ca) = (o.anchors[0].id, c.anchors[0].id);+ assert_ne!(oa, ca, "the copy's anchor has an id of its own");+ assert_eq!(d.find_anchor(ca).map(|(at, _)| at), Some(sid));+ assert_eq!((o.xrefs[0].target, c.xrefs[0].target), (oa, ca), "each cross-reference points at its own story's anchor");+ let (ot, ct) = (*o.tables.keys().next().unwrap(), *c.tables.keys().next().unwrap());+ assert_ne!(ot, ct, "the copy's table has an id of its own");+ assert_eq!(c.tables[&ct].id, ct);+ assert_eq!(c.paras.iter().filter_map(|pf| pf.table).collect::<Vec<_>>(), [ct], "the copied paragraph holds the copied table");+ d.check().unwrap();+ // The table commands are enabled with the insertion point in a table: here the original's.+ let cell = designcraft_doc::CellAddr { table: ot, row: 0, col: 0 };+ s.edit(|_, sel| {+ sel.text = Some(designcraft_doc::TextSel { story: orig, anchor: 0, focus: 0, frame: None, cell: Some(cell) });+ Ok(())+ })+ .unwrap();+ s.execute("table.insertRow", &json!({"table": ct})).unwrap();+ let d = &s.doc().unwrap().doc;+ assert_eq!(d.story(orig).unwrap().tables[&ot].nrows(), 1, "the original table is unchanged");+ assert_eq!(d.story(sid).unwrap().tables[&ct].nrows(), 2);+ }++ #[test]+ fn duplicated_text_gets_anchors_and_tables_of_its_own() {+ let mut s = crate::Session::new();+ let (_, sid) = marked_frame(&mut s);+ let copy = s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap();+ assert_own_marks(&mut s, sid, copy);+ }++ #[test]+ fn pasted_text_gets_anchors_and_tables_of_its_own() {+ let mut s = crate::Session::new();+ let (_, sid) = marked_frame(&mut s);+ s.execute("edit.copy", &json!({})).unwrap();+ let copy = s.execute("edit.paste", &json!({})).unwrap()["ids"][0].as_u64().unwrap();+ assert_own_marks(&mut s, sid, copy);+ }++ #[test]+ fn duplicated_spreads_get_anchors_and_tables_of_their_own() {+ let mut s = crate::Session::new();+ let (f, sid) = marked_frame(&mut s);+ s.execute("layout.pages.duplicateSpread", &json!({"spread": 0})).unwrap();+ let d = &s.doc().unwrap().doc;+ let copy = d.spreads[1].items.iter().find(|i| i.text_frame().is_some()).unwrap().id.0;+ assert_ne!(copy, f);+ assert_own_marks(&mut s, sid, copy);+ }++ #[test]+ fn groups_nested_too_deeply_are_not_copied() {+ let mut s = crate::Session::new();+ s.execute("file.new", &json!({})).unwrap();+ let frame = |s: &mut crate::Session| s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap();+ let mut g = frame(&mut s);+ for _ in 0..=super::MAX_COPY_DEPTH {+ let other = frame(&mut s);+ g = s.execute("object.group", &json!({"ids": [g, other]})).unwrap()["id"].as_u64().unwrap();+ }+ let text = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300], "content": "text", "text": "Host"})).unwrap()["id"].as_u64();+ let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(text.unwrap())).unwrap().text_frame().unwrap().story;+ let before = s.doc().unwrap().doc.clone();+ assert!(s.execute("transform.move", &json!({"dx": 10, "dy": 0, "copy": true, "ids": [g]})).is_err());+ assert_eq!(s.doc().unwrap().doc, before, "nothing is copied");+ // Nor pasted into text as an anchored object.+ s.execute("selection.set", &json!({"ids": [g]})).unwrap();+ s.execute("edit.copy", &json!({})).unwrap();+ s.execute("text.select", &json!({"story": sid.0, "anchor": 4, "focus": 4})).unwrap();+ assert!(s.execute("edit.paste", &json!({})).is_err());+ assert_eq!(s.doc().unwrap().doc, before, "nothing is pasted");+ }++ #[test]+ fn pasted_cross_references_follow_the_pasted_anchors_of_other_stories() {+ let mut s = crate::Session::new();+ s.execute("file.new", &json!({})).unwrap();+ let frame = |s: &mut crate::Session, rect: [f64; 4], text: &str| {+ let id = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap();+ (id, s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story)+ };+ // Each story refers to the other, so one of the two is pasted before its destination.+ let (a, first) = frame(&mut s, [72.0, 72.0, 300.0, 200.0], "Methods, see ");+ let (b, second) = frame(&mut s, [72.0, 300.0, 300.0, 400.0], "Results, see ");+ s.execute("text.select", &json!({"story": first.0, "anchor": 13, "focus": 13})).unwrap();+ s.execute("xref.insert", &json!({"paragraph": "Results"})).unwrap();+ s.execute("text.select", &json!({"story": second.0, "anchor": 13, "focus": 13})).unwrap();+ s.execute("xref.insert", &json!({"paragraph": "Methods"})).unwrap();+ s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();+ s.execute("edit.copy", &json!({})).unwrap();+ // Into another document, which holds no anchor of the original's: its stories get other ids.+ s.execute("file.new", &json!({})).unwrap();+ for _ in 0..3 {+ frame(&mut s, [0.0, 0.0, 50.0, 50.0], "");+ }+ s.execute("selection.set", &json!({"ids": []})).unwrap();+ let ids = s.execute("edit.paste", &json!({})).unwrap()["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::<Vec<_>>();+ let d = &s.doc().unwrap().doc;+ let story = |id: u64| d.story(d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story).unwrap();+ for (refers, dest) in [(story(ids[0]), story(ids[1])), (story(ids[1]), story(ids[0]))] {+ assert_eq!(refers.xrefs[0].target, dest.anchors[0].id, "the cross-reference points at the pasted destination");+ assert_eq!(d.find_anchor(refers.xrefs[0].target).map(|(at, _)| at), Some(dest.id));+ }+ }++ /// The first copy's new anchor id is the second original's anchor id, as can happen between two documents.+ #[test]+ fn copied_cross_references_keep_their_destination_when_new_ids_repeat_old_ones() {+ let mut s = crate::Session::new();+ s.execute("file.new", &json!({})).unwrap();+ s.edit(|d, _| {+ d.next_id += 1000;+ Ok(())+ })+ .unwrap();+ let frame = |s: &mut crate::Session, rect: [f64; 4], text: &str| {+ let id = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap();+ (designcraft_doc::ItemId(id), s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().story)+ };+ let (a, first) = frame(&mut s, [72.0, 72.0, 300.0, 200.0], "Methods, see ");+ let (b, second) = frame(&mut s, [72.0, 300.0, 300.0, 400.0], "Results");+ s.execute("text.select", &json!({"story": first.0, "anchor": 13, "focus": 13})).unwrap();+ s.execute("xref.insert", &json!({"paragraph": "Methods"})).unwrap();+ s.execute("text.select", &json!({"story": second.0, "anchor": 0, "focus": 0})).unwrap();+ s.execute("anchor.create", &json!({"name": "Results"})).unwrap();+ let src = s.doc().unwrap().doc.as_ref().clone();+ assert_eq!(src.story(second).unwrap().anchors[0].id, (second.0 << 24) + 1);+ // The copy of `first` gets story id `second`: one id goes to the frame, the next to its story.+ let mut t = crate::Session::new();+ t.execute("file.new", &json!({})).unwrap();+ let mut dst = t.doc().unwrap().doc.as_ref().clone();+ assert!(dst.next_id < second.0 - 2);+ dst.next_id = second.0 - 2;+ let ids = super::duplicate_from(&mut dst, &src, &[a, b], designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Vec2::ZERO).unwrap();+ let story = |id: designcraft_doc::ItemId| dst.story(dst.item(id).unwrap().text_frame().unwrap().story).unwrap();+ let (one, two) = (story(ids[0]), story(ids[1]));+ assert_eq!(one.id, second);+ assert_eq!(one.xrefs[0].target, one.anchors[0].id, "the copy still refers to its own paragraph");+ assert_ne!(two.anchors[0].id, one.anchors[0].id);+ } } #[cfg(test)]