M11: snippets (export selection with stories/styles/images; place into another document); tidy clipboard documents

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:25:25 -04:00
co-authored by Claude Opus 5.5
parent 7b35e648b3
commit 06cdac310e
3 changed files with 101 additions and 4 deletions
+26 -4
View File
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// A document holding copies of the selected items (and their stories) on spread 0.
fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
pub(crate) fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
let st = s.doc()?;
let mut d = (*st.doc).clone();
let keep: Vec<ItemId> = st.selection.items.clone();
@@ -153,11 +153,33 @@ fn clip_doc(s: &Session) -> Result<designcraft_doc::Document> {
for sp in d.spreads.iter_mut() {
Arc::make_mut(sp).items.clear();
}
// Kept frames and graphics (including inside groups).
let mut frames = std::collections::HashSet::new();
let mut assets = std::collections::HashSet::new();
for it in &items {
it.walk(&mut |i| match &i.content {
designcraft_doc::Content::Text(_) => {
frames.insert(i.id);
}
designcraft_doc::Content::Graphic(g) => {
assets.insert(g.asset);
}
_ => {}
});
}
if let Some(sp) = d.spreads.first_mut() {
Arc::make_mut(sp).items = items;
}
// Parent spreads aren't part of a clipping; stories keep only the copied frames.
for p in d.parents.iter_mut() {
Arc::make_mut(p).items.clear();
}
d.stories.retain(|_, st| st.frames.iter().any(|f| frames.contains(f)));
for st in d.stories.values_mut() {
Arc::make_mut(st).frames.retain(|f| frames.contains(f));
}
d.assets.retain(|k, _| assets.contains(k));
d.hyperlinks.clear();
d.bookmarks.clear();
Ok(d)
}
#[allow(dead_code)]
fn _unused(_: Value) {}
+57
View File
@@ -31,6 +31,17 @@ pub fn specs() -> Vec<CommandSpec> {
s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize);
ok()
}),
cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None,
"{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", super::has_selection, snippet_export),
cmd!(
"snippet.place",
"Place Snippet",
[],
None,
"{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)",
has_doc,
snippet_place
),
cmd!(
"place.load",
"Load Place Cursor",
@@ -335,3 +346,49 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
s.set_tool("selection");
Ok(r)
}
fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
let d = super::edit::clip_doc(s)?;
let bytes = to_bytes(&d);
match str_param(p, "path") {
Some(path) => {
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)}))
}
None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})),
}
}
fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
let (bytes, _, _) = read_source(p)?;
let src = from_bytes(&bytes)?;
let ids: Vec<ItemId> = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
if ids.is_empty() {
return Err(bad("snippet.place", "the snippet is empty"));
}
let sr = super::spread_param(p, "spread");
let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO);
let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
(Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0),
_ => designcraft_geom::Vec2::ZERO,
};
s.edit(|d, sel| {
// Bring over styles and swatches the snippet uses that this document lacks.
for sw in &src.swatches {
if d.swatch(&sw.name).is_none() {
d.swatches.push(sw.clone());
}
}
let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect();
let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect();
if !missing_p.is_empty() || !missing_c.is_empty() {
let st = d.styles_mut();
st.paragraph.extend(missing_p);
st.character.extend(missing_c);
}
let new = super::object::duplicate_from(d, &src, &ids, sr, off)?;
*sel = Selection::items(new.clone());
Ok(json!({"ids": new.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
+18
View File
@@ -253,6 +253,24 @@ fn step_and_repeat_grid() {
assert_eq!(last.y0, 36.0 + 2.0 * 40.0);
}
#[test]
fn snippets_roundtrip_between_documents() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
s.execute("selection.set", &json!({"ids": [39]})).unwrap();
let snip = s.execute("snippet.export", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("snippet.place", &json!({"base64": snip["base64"], "x": 36.0, "y": 36.0})).unwrap();
let id = designcraft_doc::ItemId(r["ids"][0].as_u64().unwrap());
let st = s.doc().unwrap();
let it = st.doc.item(id).unwrap();
assert_eq!(it.bounds().x0, 36.0);
let sid = it.text_frame().unwrap().story;
assert!(st.doc.story(sid).unwrap().text.starts_with("Every page begins"));
assert!(st.doc.styles.para("Body").is_some());
st.doc.check().unwrap();
}
/// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText).
fn pdf_text(bytes: &[u8]) -> Vec<String> {
use hayro_interpret::font::Glyph;