Files
designcraft/crates/engine/src/cmd/anchored.rs
T
aiongg d2bdb0866f Paste: other apps' text goes into a text caret after copying objects (#1344)
* Paste: other apps' text goes into a text caret even after copying objects (#212)

* Paste: text that matches a copied frame still pastes; groups with text frames aren't anchored (#212)
2026-10-22 09:26:43 -04:00

321 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Object › Anchored Object: items that flow with the text (inline or above the line).
use std::sync::Arc;
use designcraft_doc::{
AnchorPosition, AnchoredObject, Content, Document, Item, ItemId, OBJECT_MARK, SpreadRef, StoryId, TextSel, anchored::AnchorAlign,
};
use designcraft_geom::Affine;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"anchored.insert",
"Insert Anchored Object…",
["Object", "Anchored Object"],
None,
"{ids, story?, pos? (default: the text insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?} — moves the items into the text",
has_doc,
insert
),
cmd!(
"anchored.options",
"Anchored Object Options…",
["Object", "Anchored Object"],
None,
"{story?, index? (default: the object at the insertion point), position?: inline|aboveLine|custom, yOffset?, align?: left|center|right, spaceBefore?, spaceAfter?, xRelative? | yRelative?: anchor|textFrame|columnEdge|pageMargin|pageEdge, xOffset?, objectPoint? | refPoint?: 0..8, keepWithinColumn?}",
has_doc,
options
),
cmd!(
"anchored.release",
"Release",
["Object", "Anchored Object"],
None,
"{story?, index? (default: the object at the insertion point)} — puts the object back on the page where it is now and removes it from the text",
has_doc,
release
),
cmd!(query "anchored.list", "Anchored Objects", [], None, "{story?} → [{story, index, pos, position, size}]", has_doc, |s, p| {
let d = &s.doc()?.doc;
let only = p.get("story").and_then(Value::as_u64).map(StoryId);
let mut out = Vec::new();
for st in d.stories.values().filter(|st| only.is_none_or(|o| o == st.id)) {
for (k, ((pos, _), o)) in st.text.match_indices(OBJECT_MARK).zip(&st.objects).enumerate() {
out.push(json!({"story": st.id.0, "index": k, "pos": pos, "position": o.position, "size": o.size()}));
}
}
Ok(Value::Array(out))
}),
]
}
fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
let f = |k: &str, d: f64| p.get(k).and_then(Value::as_f64).unwrap_or(d);
let kind = str_param(p, "position").unwrap_or(match base {
AnchorPosition::Inline { .. } => "inline",
AnchorPosition::AboveLine { .. } => "aboveLine",
AnchorPosition::Custom { .. } => "custom",
});
Ok(match (kind, base) {
("inline", AnchorPosition::Inline { y_offset }) => AnchorPosition::Inline { y_offset: f("yOffset", *y_offset) },
("inline", _) => AnchorPosition::Inline { y_offset: f("yOffset", 0.0) },
("aboveLine", b) => {
let (a0, sb0, sa0) = match b {
AnchorPosition::AboveLine { align, space_before, space_after } => (*align, *space_before, *space_after),
_ => (AnchorAlign::Left, 0.0, 0.0),
};
let align = match str_param(p, "align") {
Some("center") => AnchorAlign::Center,
Some("right") => AnchorAlign::Right,
Some("left") => AnchorAlign::Left,
Some(o) => return Err(bad("anchored", format!("unknown align `{o}`"))),
None => a0,
};
AnchorPosition::AboveLine { align, space_before: f("spaceBefore", sb0), space_after: f("spaceAfter", sa0) }
}
("custom", b) => {
use designcraft_doc::anchored::AnchorRelative as R;
let (xr0, yr0, xo0, yo0, op0, rp0, k0) = match b {
AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
(*x_relative, *y_relative, *x_offset, *y_offset, *object_point, *ref_point, *keep_within_column)
}
_ => (R::TextFrame, R::Anchor, 0.0, 0.0, 0, 0, false),
};
let rel = |k: &str, d: R| -> Result<R> {
match p.get(k) {
Some(v) => serde_json::from_value(v.clone())
.map_err(|_| bad("anchored", format!("`{k}`: anchor|textFrame|columnEdge|pageMargin|pageEdge"))),
None => Ok(d),
}
};
let pt = |k: &str, d: u8| p.get(k).and_then(Value::as_u64).map_or(d, |v| v.min(8) as u8);
AnchorPosition::Custom {
x_relative: rel("xRelative", xr0)?,
y_relative: rel("yRelative", yr0)?,
x_offset: f("xOffset", xo0),
y_offset: f("yOffset", yo0),
object_point: pt("objectPoint", op0),
ref_point: pt("refPoint", rp0),
keep_within_column: p.get("keepWithinColumn").and_then(Value::as_bool).unwrap_or(k0),
}
}
(o, _) => return Err(bad("anchored", format!("unknown position `{o}`"))),
})
}
/// Text frames can't be anchored yet (their stories need frames on a spread).
pub(crate) fn check_item(it: &Item) -> Result<()> {
let mut ok = true;
it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_)));
if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) }
}
/// 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 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();
}
Ok(at)
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::ids_param(p, "ids").ok_or_else(|| bad("anchored.insert", "missing ids"))?;
let position = position_param(p, &AnchorPosition::default())?;
let st = s.doc()?;
let (sid, pos) = match (p.get("story").and_then(Value::as_u64), p.get("pos").and_then(Value::as_u64)) {
(Some(sid), pos) => (StoryId(sid), pos.map(|v| v as usize).unwrap_or(usize::MAX)),
_ => {
let t = st
.selection
.text
.filter(|t| t.cell.is_none())
.ok_or_else(|| bad("anchored.insert", "give `story` or place the insertion point in story text"))?;
(t.story, t.range().start)
}
};
let mut items = Vec::new();
for id in &ids {
let loc = st.doc.find(*id).ok_or_else(|| bad("anchored.insert", format!("no item {}", id.0)))?;
let it = st.doc.item(*id).ok_or_else(|| bad("anchored.insert", format!("no item {}", id.0)))?;
check_item(it)?;
// Into spread coordinates (items inside groups carry their parents' transforms).
let mut it = it.clone();
it.xf = st.doc.parent_xf(&loc) * it.xf;
items.push(it);
}
s.edit(|d, sel| {
for id in &ids {
d.remove_item(*id)?;
}
let end = anchor_items(d, sid, pos, items.clone(), &position)?;
*sel = designcraft_doc::Selection::text(TextSel { story: sid, anchor: end, focus: end, frame: None, cell: None });
Ok(json!({"story": sid.0, "pos": end}))
})
}
/// `{story, index}`, or the anchored object at (or just before) the text insertion point.
fn object_ref(s: &Session, p: &Value, cmd: &str) -> Result<(StoryId, usize)> {
if let (Some(sid), Some(k)) = (p.get("story").and_then(Value::as_u64), p.get("index").and_then(Value::as_u64)) {
return Ok((StoryId(sid), k as usize));
}
let st = s.doc()?;
let t = st
.selection
.text
.filter(|t| t.cell.is_none())
.ok_or_else(|| bad(cmd, "give `story` and `index`, or put the insertion point next to an anchored object"))?;
let story = st.doc.story(t.story).ok_or_else(|| bad(cmd, "no such story"))?;
let r = t.range();
let at = [r.start, r.start.saturating_sub(OBJECT_MARK.len_utf8())]
.into_iter()
.chain(story.text[r.clone()].find(OBJECT_MARK).map(|i| r.start + i))
.find(|&i| story.text.get(i..).is_some_and(|x| x.starts_with(OBJECT_MARK)))
.ok_or_else(|| bad(cmd, "no anchored object at the insertion point"))?;
Ok((t.story, story.text[..at].matches(OBJECT_MARK).count()))
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let (sid, k) = object_ref(s, p, "anchored.options")?;
let cur = s.doc()?.doc.story(sid).and_then(|st| st.objects.get(k).cloned()).ok_or_else(|| bad("anchored.options", "no such anchored object"))?;
let position = position_param(p, &cur.position)?;
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let o = st.objects.get_mut(k).ok_or_else(|| bad("anchored.options", "no such anchored object"))?;
Arc::make_mut(o).position = position.clone();
st.rev = st.rev.wrapping_add(1);
Ok(json!({"position": position}))
})
}
fn release(s: &mut Session, p: &Value) -> Result<Value> {
let (sid, k) = object_ref(s, p, "anchored.release")?;
let d0 = s.doc()?.doc.clone();
let st = d0.story(sid).ok_or_else(|| bad("anchored.release", "no such story"))?;
let obj = st.objects.get(k).cloned().ok_or_else(|| bad("anchored.release", "no such anchored object"))?;
let pos = st.text.match_indices(OBJECT_MARK).nth(k).map(|(i, _)| i).ok_or_else(|| bad("anchored.release", "no mark"))?;
// Where it shows now: its frame's transform and its placement.
let cs = s.cache.get(&d0, sid, None);
let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|o| o.index == k).map(|o| (ft.frame, o.origin)));
let (spread, xf) = match placed {
Some((fid, origin)) => {
let loc = d0.find(fid).ok_or_else(|| bad("anchored.release", "frame not found"))?;
let fxf = d0.parent_xf(&loc) * d0.item(fid).map_or(Affine::IDENTITY, |i| d0.text_xf(i));
(loc.spread, fxf * Affine::translate(origin.to_vec2()))
}
None => (SpreadRef::Doc(0), Affine::IDENTITY),
};
s.edit(|d, sel| {
let mut it = obj.item.clone();
it.id = ItemId(d.alloc());
it.xf = xf * it.xf;
if d.layer(it.layer).is_none() {
it.layer = d.default_layer();
}
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());
*sel = designcraft_doc::Selection::items(vec![id]);
Ok(json!({"id": id.0}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inline_object_flows_with_text_and_releases() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})).unwrap();
let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
let r = s.execute("frame.create", &json!({"rect": [450, 450, 474, 490]})).unwrap();
let box_id = r["id"].as_u64().unwrap();
s.execute("anchored.insert", &json!({"ids": [box_id], "story": sid.0, "pos": 7})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.item(ItemId(box_id)).is_none(), "moved into the text");
let st = d.story(sid).unwrap();
assert_eq!(st.objects.len(), 1);
assert_eq!(st.objects[0].size(), (24.0, 40.0));
let cs = s.cache.get(&d, sid, None);
let ft = &cs.frames[0];
assert_eq!(ft.objects.len(), 1);
let o = &ft.objects[0];
let line = &ft.lines[o.line];
// Inline: bottom on the baseline, the line grows to fit it.
assert!((o.origin.y + 40.0 - line.baseline).abs() < 1e-6);
assert!(line.ascent >= 40.0);
// The text after it moved right by its width.
let after = line.glyphs.iter().find(|g| g.byte == 7 + OBJECT_MARK.len_utf8()).unwrap();
assert!(after.x >= o.origin.x + 24.0 - 1e-6);
// Typing before it moves it along.
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("text.insert", &json!({"text": "Words "})).unwrap();
let d = s.doc().unwrap().doc.clone();
let x2 = s.cache.get(&d, sid, None).frames[0].objects[0].origin.x;
assert!(x2 > o.origin.x);
// Above line: the object sits over its line, which moves down.
s.execute("anchored.options", &json!({"story": sid.0, "index": 0, "position": "aboveLine", "align": "center", "spaceAfter": 4.0})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
let o = &cs.frames[0].objects[0];
let line = &cs.frames[0].lines[o.line];
assert!(o.origin.y + 40.0 + 4.0 <= line.baseline - 5.0, "{o:?} {}", line.baseline);
// Release: back on the page where it was shown.
s.execute("anchored.release", &json!({"story": sid.0, "index": 0})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert!(d.story(sid).unwrap().objects.is_empty());
let id = s.doc().unwrap().selection.items[0];
let b = d.item(id).unwrap().bounds();
// (The frame's inner space is spread space here.)
assert!((b.width() - 24.0).abs() < 1e-6 && (b.x0 - o.origin.x).abs() < 1e-6 && (b.y0 - o.origin.y).abs() < 1e-6, "{b:?}");
d.check().unwrap();
}
}
#[cfg(test)]
mod custom_tests {
use serde_json::json;
use crate::Session;
#[test]
fn custom_anchored_object_sits_in_the_page_margin_and_takes_no_space() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Side note here"})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let end_before = s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines[0].end_x;
let box_id = s.execute("frame.create", &json!({"rect": [0, 0, 30, 20], "content": "unassigned"})).unwrap()["id"].clone();
// To the left of the page margin, top of the box on the anchor's baseline.
s.execute(
"anchored.insert",
&json!({"ids": [box_id], "story": sid.0, "pos": 4, "position": "custom", "xRelative": "pageMargin", "yRelative": "anchor", "refPoint": 0, "objectPoint": 2, "xOffset": -6}),
)
.unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
let ft = &cs.frames[0];
assert!((ft.lines[0].end_x - end_before).abs() < 1e-6, "the marker takes no space");
let o = &ft.objects[0];
// The frame's inner space is the spread's here (no rotation or offset).
let margin_x = d.spreads[0].pages[0].margin_rect().x0;
assert!((o.origin.x + o.size.0 - (margin_x - 6.0)).abs() < 1e-6, "right edge 6 pt left of the margin: {:?}", o.origin);
assert!((o.origin.y - ft.lines[0].baseline).abs() < 1e-6);
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
let obj = back.stories.values().flat_map(|st| st.objects.iter()).next().unwrap();
assert!(
matches!(obj.position, designcraft_doc::AnchorPosition::Custom { x_offset, object_point: 2, .. } if x_offset == -6.0),
"IDML Anchored position"
);
}
}