Files
designcraft/crates/engine/src/cmd/edit.rs
T

855 lines
44 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.
//! Edit menu: undo/redo, selection, clear, clipboard.
use std::sync::Arc;
use designcraft_doc::{ItemId, Selection, SpreadRef};
use serde_json::{Value, json};
use super::{CommandSpec, bool_or, cmd, has_doc, has_selection, ids_param, ok};
use crate::{HistoryEntry, Result, Session};
fn can_undo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
}
fn can_redo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into())
}
fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let text = s.text_clipboard.is_some() && s.active().is_some_and(|d| d.selection.text.is_some());
if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) }
}
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) {
return Ok(());
}
has_selection(s)
}
/// Plain text of a story slice: markers and anchored objects dropped, footnote text omitted.
fn plain(st: &designcraft_doc::Story) -> String {
st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect()
}
/// Copy the selected text with its formatting. Returns its plain text (for the system clipboard).
fn copy_text(s: &mut Session) -> Result<Option<String>> {
let st = s.doc()?;
let Some(t) = st.selection.text.filter(|t| !t.is_caret()) else { return Ok(None) };
let Some(story) = st.doc.text_story(t.story, t.cell) else { return Ok(None) };
let slice = story.extract(t.range());
let text = plain(&slice);
s.text_clipboard = Some((Arc::new(slice), text.clone()));
Ok(Some(text))
}
/// Plain text for the system clipboard after copying objects: the copied text frames' stories,
/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the
/// copied objects", not as text.
pub fn copied_objects_text(s: &Session) -> String {
let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() };
let mut stories = Vec::new();
for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() {
it.walk(&mut |i| {
if let Some(tf) = i.text_frame()
&& !stories.contains(&tf.story)
{
stories.push(tf.story);
}
});
}
let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n");
if text.is_empty() { '\u{FFFC}'.to_string() } else { text }
}
/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and
/// it is their placeholder, so Paste stands for the copied objects.
fn is_objects_placeholder(s: &Session, text: &str) -> bool {
s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n")
}
/// Paste text at the insertion point: the formatted copy when the system clipboard still holds
/// what was copied here (or `formatted` is forced), else plain text in the insertion format.
/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their
/// placeholder; other text was copied in another app since. Objects that can't be anchored (text
/// frames, into a table cell) paste as their placeholder text.
fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<Value> {
let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?;
let clip = s.text_clipboard.clone();
let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x));
if own
&& t.cell.is_none()
&& let Some(items) = s.clipboard.clone()
{
let list: Vec<designcraft_doc::Item> = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default();
if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) {
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()));
// 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
.iter()
.map(|i| {
let mut i = i.clone();
super::object::renumber(d, &items, &mut i, &mut stories);
if d.layer(i.layer).is_none() {
i.layer = d.default_layer();
}
i
})
.collect();
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()}))
});
}
}
let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p));
// 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() };
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);
sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t });
Ok(json!({"pos": end, "formatted": true}))
});
}
// U+FFFC stands for objects with no text.
let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text };
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
let text = outside_text(&text)?;
s.execute("text.insert", &json!({"text": text, "raw": true}))
}
/// Text from the system clipboard as story text: Unix line ends, and none of the private-use
/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and
/// variables, since a stray one would be read as a marker without its payload.
fn outside_text(text: &str) -> Result<String> {
let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) }
}
/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column
/// of the page's margin area and as tall as the text (at most the margin area's height), centred
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With
/// `slice` (the text copied here), the frame's story is that formatted copy.
fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> {
use designcraft_geom::{Point, Rect, shapes};
const LIMIT: f64 = 1.0e5;
let sr = super::spread_param(p, "edit.paste")?;
let at = super::point_param(p, "at")
.filter(|a| a.x.is_finite() && a.y.is_finite())
.map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT)));
let lid = s.doc()?.active_layer;
let cache = s.cache.clone();
let text = if slice.is_some() { String::new() } else { outside_text(text)? };
s.edit(|d, sel| {
let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?;
let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first());
let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?;
let margins = page.margin_rect();
let column = page.column_rects().first().copied().unwrap_or(margins);
let w = column.width().max(12.0);
let max_h = margins.height().max(12.0);
let c = at.unwrap_or(Point::new(column.center().x, margins.center().y));
let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
let para = match slice.and_then(|sl| sl.paras.first()) {
Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() },
None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() },
};
let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?;
if let Some(sl) = slice {
d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl);
}
// Shrink to the composed text unless it already fills the frame.
let composed = cache.get(d, sid, None);
if !composed.is_overset()
&& let Some(ft) = composed.frame(id)
{
let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]);
let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h);
if h.is_finite()
&& h < max_h
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(h));
// Keep the full height if the last line no longer fits.
if cache.get(d, sid, None).is_overset()
&& let Some(it) = d.item_mut(id)
{
it.path = shapes::rectangle(rect(max_h));
}
}
}
*sel = Selection::items(vec![id]);
Ok(json!({"id": id.0, "story": sid.0}))
})
}
fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story {
let mut out = st.clone();
let marks = [
designcraft_doc::FOOTNOTE_REF,
designcraft_doc::ENDNOTE_REF,
designcraft_doc::NOTE_MARK,
designcraft_doc::XREF_MARK,
designcraft_doc::TABLE_ANCHOR,
];
while let Some(i) = out.text.find(marks) {
let n = out.text[i..].chars().next().map_or(1, char::len_utf8);
out.delete(i..i + n);
}
out
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| {
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| super::bad("edit.undo", "nothing to undo"))?;
st.history.redo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
let mut doc = e.doc;
super::rulers::keep_zero_point(&st.doc, &mut doc);
st.doc = doc;
st.selection = e.selection;
st.bump_revision();
// Pending typing overrides belong to the current edit state, not a restored snapshot.
s.typing_format = None;
Ok(json!({"undone": e.label}))
}),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| super::bad("edit.redo", "nothing to redo"))?;
st.history.undo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
let mut doc = e.doc;
super::rulers::keep_zero_point(&st.doc, &mut doc);
st.doc = doc;
st.selection = e.selection;
st.bump_revision();
s.typing_format = None;
Ok(json!({"redone": e.label}))
}),
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
let ids = ids_param(p, "ids").unwrap_or_default();
let add = bool_or(p, "add", false);
let content = bool_or(p, "content", false);
let st = s.doc_mut()?;
let ids: Vec<ItemId> = ids.into_iter().filter(|i| st.doc.item(*i).is_some()).collect();
if add {
for i in ids {
if !st.selection.items.contains(&i) {
st.selection.items.push(i);
}
}
} else {
st.selection = Selection { items: ids, content, ..Default::default() };
}
st.selection.text = None;
st.selection.cells = None;
st.bump_revision();
ok()
}),
cmd!(noundo "selection.toggle", "Toggle Selection", [], None, "{id}", has_doc, |s, p| {
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.toggle", "missing id"))?;
let st = s.doc_mut()?;
st.selection.text = None;
st.selection.cells = None;
if let Some(i) = st.selection.items.iter().position(|x| *x == id) {
st.selection.items.remove(i);
// A key object needs another object to align to it.
if st.selection.key == Some(id) || st.selection.items.len() < 2 {
st.selection.key = None;
}
} else {
st.selection.items.push(id);
}
st.bump_revision();
ok()
}),
cmd!(noundo "selection.key", "Key Object", [], None, "{id} — make a selected item the key object for Align; the current key object drops the key", has_doc, |s, p| {
let id = super::id_param(p, "id").ok_or_else(|| super::bad("selection.key", "missing id"))?;
let st = s.doc_mut()?;
if !st.selection.items.contains(&id) {
return Err(super::bad("selection.key", "the key object must be selected"));
}
if st.selection.items.len() < 2 {
return Err(super::bad("selection.key", "a key object needs at least two selected objects"));
}
st.selection.key = if st.selection.key == Some(id) { None } else { Some(id) };
st.bump_revision();
ok()
}),
cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| {
let st = s.doc_mut()?;
if let Some(t) = st.selection.text {
let len = st.doc.text_story(t.story, t.cell).map(|x| x.len()).unwrap_or(0);
st.selection.text = Some(designcraft_doc::TextSel { anchor: 0, focus: len, ..t });
} else {
let mut ids = Vec::new();
let parents = st.editing_parents;
let refs: Vec<SpreadRef> = st.doc.spread_refs().filter(|r| matches!(r, SpreadRef::Parent(_)) == parents).collect();
for r in refs {
if let Some(sp) = st.doc.spread(r) {
for it in &sp.items {
let locked = it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible);
if !locked && !it.hidden {
ids.push(it.id);
}
}
}
}
st.selection = Selection::items(ids);
}
st.bump_revision();
ok()
}),
cmd!(noundo "edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", has_doc, |s, _| {
let st = s.doc_mut()?;
st.selection = Selection::default();
st.bump_revision();
ok()
}),
cmd!(
"edit.clear",
"Clear",
["Edit"],
Some("Delete"),
"{ids?} — delete selected items (or the selected text or ruler guides)",
has_doc,
|s, p| {
if s.doc()?.selection.text.is_some() && p.get("ids").is_none() {
return super::text::delete_selection(s);
}
if p.get("ids").is_none() && s.doc()?.selection.items.is_empty() && !s.doc()?.selection.guides.is_empty() {
return s.execute("guide.delete", &json!({}));
}
let ids = super::targets(s, p)?;
// With a placed graphic itself selected (Direct Selection), only the graphic goes: its
// frame stays, empty and selected (as in InDesign). Other items are deleted.
let content = s.doc()?.selection.content && p.get("ids").is_none();
s.edit(|d, sel| {
let mut kept = Vec::new();
for id in &ids {
if let Some(it) = d.item_mut(*id).filter(|it| content && matches!(it.content, designcraft_doc::Content::Graphic(_))) {
it.content = designcraft_doc::Content::Unassigned;
kept.push(*id);
} else {
let _ = d.remove_item(*id);
}
}
*sel = Selection::items(kept);
Ok(json!({"deleted": ids.len()}))
})
}
),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{} → {text} when text is selected (with formatting, footnotes, markers)", has_selection_or_text, |s, _| {
if let Some(text) = copy_text(s)? {
return Ok(json!({"text": text}));
}
copy_items(s)?;
s.text_clipboard = None;
ok()
}),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection_or_text, |s, p| {
if let Some(text) = copy_text(s)? {
super::text::delete_selection(s)?;
return Ok(json!({"text": text}));
}
copy_items(s)?;
s.execute("edit.clear", p)
}),
cmd!(
"edit.pasteWithoutFormatting",
"Paste without Formatting",
["Edit"],
Some("Cmd+Shift+V"),
"{text?} — plain text at the insertion point, in the format there",
super::has_text,
|s, p| paste_text(s, super::str_param(p, "text").map(str::to_string), false)
),
cmd!(
"edit.paste",
"Paste",
["Edit"],
Some("Cmd+V"),
"{inPlace?: bool, spread?: index (0), text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), at?: [x, y] (spread coords: the new text frame's centre)}",
// The system clipboard may hold text from another app, so Paste is always available.
has_doc,
|s, p| {
let text = super::str_param(p, "text").filter(|t| !t.is_empty());
if s.doc()?.selection.text.is_some() {
return paste_text(s, text.map(str::to_string), true);
}
// The placeholder of an object copy pastes the copied objects.
if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) {
// What our own text copy put on the clipboard keeps its formatting.
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
return paste_text_frame(s, p, text, slice.as_deref());
}
let Some(clip) = s.clipboard.clone() else {
return Err(super::bad("edit.paste", "clipboard is empty"));
};
let off = if bool_or(p, "inPlace", false) { 0.0 } else { 12.0 };
let to = super::spread_param(p, "edit.paste")?;
let from = s.clipboard_origin;
let ids: Vec<ItemId> = clip.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
s.edit(|d, sel| {
// The same place on another spread is the same distance from its spine or left edge.
let dx = d.spread(to).map_or(0.0, |sp| paste_origin(sp) - from);
let new = super::object::duplicate_from(d, &clip, &ids, to, designcraft_geom::Vec2::new(dx + off, off))?;
*sel = Selection::items(new.clone());
Ok(json!({"ids": new.iter().map(|i| i.0).collect::<Vec<_>>()}))
})
}
),
cmd!(
"edit.pasteInto",
"Paste Into",
["Edit"],
Some("Cmd+Alt+V"),
"{id?} — the copied objects become the content of the selected frame (or `id`), clipped by it",
has_clip,
paste_into
),
cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Alt+Shift+V"), "{spread?: index (0)}", has_clip, |s, p| s
.execute("edit.paste", &json!({"inPlace": true, "spread": p.get("spread")}))),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Alt+Shift+D"), "{}", has_selection, |s, _| s
.execute("transform.move", &json!({"dx": 12.0, "dy": 12.0, "copy": true}))),
// Live previews (a panel slider being dragged): the change shows on the page, and only
// `edit.commitPreview` makes it one undo step.
cmd!(
query "edit.preview",
"Preview Change",
[],
None,
"{label, command, params?} — run `command` on the document as it was before the preview began, replacing the last preview; the first call opens a preview named `label` (the undo step's name)",
has_doc,
preview
),
cmd!(query "edit.commitPreview", "Commit Preview", [], None, "{} — the open preview becomes one undo step", has_doc, |s, _| {
s.commit_interaction()?;
ok()
}),
cmd!(query "edit.cancelPreview", "Cancel Preview", [], None, "{} — the document goes back to how it was before the preview", has_doc, |s, _| {
s.cancel_interaction();
ok()
}),
]
}
/// Put the selected items on the clipboard, with the x Paste measures them from on their spread.
fn copy_items(s: &mut Session) -> Result<()> {
let clip = clip_doc(s)?;
let st = s.doc()?;
let spread = st.selection.items.iter().find_map(|id| st.doc.find(*id)).and_then(|loc| st.doc.spread(loc.spread));
s.clipboard_origin = spread.map_or(0.0, paste_origin);
s.clipboard = Some(Arc::new(clip));
Ok(())
}
/// The x a pasted object keeps its distance from: the spine, or the left edge of single-sided
/// pages, which have none.
fn paste_origin(sp: &designcraft_doc::Spread) -> f64 {
if sp.pages.iter().all(|p| p.side == designcraft_doc::PageSide::Single) { sp.bounds().x0 } else { sp.spine_x() }
}
/// `edit.preview`: open the preview (unless one of the same name is open) and show `command` on it.
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
let label = super::str_param(p, "label").filter(|l| !l.is_empty()).ok_or_else(|| super::bad("edit.preview", "missing label"))?;
let command = super::str_param(p, "command").ok_or_else(|| super::bad("edit.preview", "missing command"))?;
// Only an edit can be previewed: a query, undo or another preview command would not be one undo step.
if !super::find_command(command).is_some_and(|c| c.undoable) {
return Err(super::bad("edit.preview", format!("{command} is not an edit that can be previewed")));
}
let params = p.get("params").cloned().unwrap_or_else(|| json!({}));
let opened = match s.doc()?.interaction.as_ref().map(|i| i.label.as_str()) {
None => {
s.begin_interaction(label)?;
true
}
Some(open) if open == label => false,
// A tool's drag (or another preview) is in progress: leave it alone.
Some(open) => return Err(super::bad("edit.preview", format!("{open} is in progress"))),
};
if let Err(e) = s.preview(command, params) {
// A preview this call opened must not stay open: later edits would get no undo step.
if opened {
s.cancel_interaction();
}
return Err(e);
}
ok()
}
/// A document holding copies of the selected items (and their stories) on spread 0.
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();
// Gather the selected items (in their spread coordinates, nested ones too) into spread 0.
let mut items = Vec::new();
for id in &keep {
let (Some(it), Some(loc)) = (st.doc.item(*id), st.doc.find(*id)) else { continue };
let parent = st.doc.parent_xf(&loc);
items.push(Arc::new(match &it.content {
designcraft_doc::Content::Graphic(g) if st.selection.content => graphic_alone(it, g, parent),
_ => designcraft_doc::Item { xf: parent * it.xf, ..it.clone() },
}));
}
for sp in d.spreads.iter_mut() {
Arc::make_mut(sp).items.clear();
}
// Kept frames (including inside groups).
let mut frames = std::collections::HashSet::new();
for it in &items {
it.walk(&mut |i| {
if let designcraft_doc::Content::Text(_) = &i.content {
frames.insert(i.id);
}
});
}
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));
}
// Graphics on the items and in the kept stories (anchored objects, graphic cells, notes).
let assets = d.used_assets();
d.assets.retain(|k, _| assets.contains(k));
d.hyperlinks.clear();
d.bookmarks.clear();
Ok(d)
}
/// A placed graphic on its own, as InDesign copies one selected with Direct Selection: a frame of
/// the whole graphic's bounds (uncropped), where the graphic sits on the spread (`parent` is the
/// frame's parent transform), without the frame's fill, stroke or effects.
fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, parent: designcraft_geom::Affine) -> designcraft_doc::Item {
let r = designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1);
let mut it = designcraft_doc::Item::new(frame.id, frame.layer, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
it.xf = parent * frame.xf * g.xf;
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
xf: designcraft_geom::Affine::IDENTITY,
auto_fit: designcraft_doc::Fitting::None,
crop: [0.0; 4],
..g.clone()
});
it
}
#[cfg(test)]
mod text_clipboard_tests;
fn paste_into(s: &mut Session, p: &Value) -> Result<Value> {
use super::object::RemoveKeep;
let clip = s.clipboard.clone().ok_or_else(|| super::bad("edit.pasteInto", "the clipboard holds no objects"))?;
let target = match super::id_param(p, "id") {
Some(id) => id,
None => {
let st = s.doc()?;
match st.selection.items.as_slice() {
[one] => *one,
_ => return Err(super::bad("edit.pasteInto", "select one frame to paste into")),
}
}
};
let ids: Vec<ItemId> = clip.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default();
if ids.is_empty() {
return Err(super::bad("edit.pasteInto", "the clipboard holds no objects"));
}
s.edit(|d, sel| {
let loc = d.find(target).ok_or(designcraft_doc::DocError::NoItem(target))?;
let frame = d.item_at(&loc).ok_or(designcraft_doc::DocError::NoItem(target))?.clone();
if frame.is_group() || frame.is_text_frame() {
return Err(super::bad("edit.pasteInto", "paste into a graphic or empty frame (not a group or text frame)"));
}
let world = d.parent_xf(&loc) * frame.xf;
// Copies on the frame's spread (stories and images come along), then nested in the frame.
let new = super::object::duplicate_from(d, &clip, &ids, loc.spread, designcraft_geom::Vec2::ZERO)?;
let mut kids = Vec::new();
for id in &new {
kids.push(d.remove_item_keep_story(*id)?);
}
// Centre the content on the frame when it doesn't overlap it (like InDesign).
let fb = frame.bounds();
let cb = kids.iter().map(|k| k.bounds()).reduce(|a, b| a.union(b)).unwrap_or(fb);
let shift = if cb.intersect(fb).area() > 0.0 { designcraft_geom::Vec2::ZERO } else { fb.center().to_vec2() - cb.center().to_vec2() };
let inv = world.inverse();
let kids: Vec<Arc<designcraft_doc::Item>> = kids
.into_iter()
.map(|mut k| {
k.xf = inv * designcraft_geom::Affine::translate(shift) * k.xf;
Arc::new(k)
})
.collect();
let it = d.item_mut(target).ok_or(designcraft_doc::DocError::NoItem(target))?;
it.content = designcraft_doc::Content::Group { items: kids };
*sel = Selection::items(vec![target]);
Ok(json!({"id": target.0, "items": new.len()}))
})
}
#[cfg(test)]
mod paste_into_tests {
use super::*;
#[test]
fn paste_into_nests_and_clips() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap();
let frame = s.execute("frame.create", &json!({"rect": [150, 150, 250, 250]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
s.execute("selection.set", &json!({"ids": [frame]})).unwrap();
s.execute("edit.pasteInto", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
assert_eq!(d.spreads[0].items.len(), 1, "the black square is inside the frame now");
let f = d.item(ItemId(frame)).unwrap();
assert!(f.has_nested_items() && !f.is_group());
assert_eq!(f.bounds(), designcraft_geom::Rect::new(150.0, 150.0, 250.0, 250.0), "bounds stay the frame's");
// Rendered clipped: black inside the frame, nothing outside it.
let cache = designcraft_compose::Cache::new();
let mut r = designcraft_render::Renderer::new();
let img = r.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap();
assert!(img.pixel(200, 200)[0] < 90, "{:?}", img.pixel(200, 200));
assert!(img.pixel(120, 120)[0] > 200, "{:?}", img.pixel(120, 120));
s.doc().unwrap().doc.check().unwrap();
}
#[test]
fn a_selected_graphic_copies_and_clears_on_its_own() {
let dir = std::env::temp_dir().join(format!("dc-graphic-alone-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("photo.png");
std::fs::write(&png, designcraft_render::Rendered { width: 200, height: 100, pixels: vec![40u8; 200 * 100 * 4] }.to_png()).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// A square frame showing the middle of a wide photo (cropped left and right).
let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
s.execute("file.place", &json!({"path": png.to_string_lossy(), "frame": frame})).unwrap();
s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [frame]})).unwrap();
s.execute("object.stroke", &json!({"ids": [frame], "swatch": "[Black]", "weight": 4})).unwrap();
// Direct Selection on the photo, Copy: the whole photo, where it is, without the frame's stroke.
s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let clip = s.clipboard.clone().unwrap();
let it = &clip.spreads[0].items[0];
assert!(matches!(&it.content, designcraft_doc::Content::Graphic(g) if g.crop == [0.0; 4]));
assert_eq!(it.bounds(), designcraft_geom::Rect::new(50.0, 100.0, 250.0, 200.0));
assert!(it.stroke.weight == 0.0 || it.stroke.swatch == "[None]", "{:?}", it.stroke);
// Paste Into a shape over it: the photo lands in place, inside the shape.
let shape = s.execute("frame.create", &json!({"rect": [60, 90, 240, 210], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [shape]})).unwrap();
s.execute("edit.pasteInto", &json!({})).unwrap();
let d = s.doc().unwrap().doc.clone();
let kid = d.item(ItemId(shape)).unwrap().children()[0].clone();
let at = d.parent_xf(&d.find(kid.id).unwrap()) * kid.xf;
assert!((at * designcraft_geom::Point::new(0.0, 0.0) - designcraft_geom::Point::new(50.0, 100.0)).hypot() < 1e-6);
assert!((at * designcraft_geom::Point::new(200.0, 100.0) - designcraft_geom::Point::new(250.0, 200.0)).hypot() < 1e-6);
// Delete with the photo selected: the frame stays, empty.
s.execute("selection.set", &json!({"ids": [frame], "content": true})).unwrap();
assert_eq!(s.execute("edit.clear", &json!({})).unwrap()["deleted"], 1);
let d = s.doc().unwrap().doc.clone();
assert!(matches!(d.item(ItemId(frame)).unwrap().content, designcraft_doc::Content::Unassigned));
assert_eq!(s.doc().unwrap().selection, Selection::items(vec![ItemId(frame)]));
d.check().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod paste_tests {
use super::*;
use designcraft_geom::Rect;
fn pasted(s: &mut Session, id: &str, p: Value) -> (SpreadRef, Rect) {
let new = ItemId(s.execute(id, &p).unwrap()["ids"][0].as_u64().unwrap());
let d = &s.doc().unwrap().doc;
(d.find(new).unwrap().spread, d.item(new).unwrap().bounds())
}
/// Paste and Paste in Place put the objects on the spread they are given (the first without one).
#[test]
fn paste_puts_the_objects_on_the_given_spread() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [36, 36, 136, 136]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 2})), (SpreadRef::Doc(2), Rect::new(36.0, 36.0, 136.0, 136.0)));
assert_eq!(pasted(&mut s, "edit.paste", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(48.0, 48.0, 148.0, 148.0)));
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({})).0, SpreadRef::Doc(0));
// A spread the document doesn't have: an error, and nothing is pasted.
let before = s.doc().unwrap().doc.clone();
assert!(s.execute("edit.paste", &json!({"spread": 9})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
before.check().unwrap();
}
/// With facing pages the first page sits alone, right of the spine. An object keeps its side
/// of the spine when it is pasted in place on another spread.
#[test]
fn paste_in_place_keeps_the_side_of_the_spine() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 4, "facingPages": true})).unwrap();
let w = s.doc().unwrap().doc.settings.page_width;
// On page 3, the right-hand page of the second spread.
let a = s.execute("frame.create", &json!({"spread": 1, "rect": [w + 36.0, 36, w + 136.0, 136]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 0})), (SpreadRef::Doc(0), Rect::new(36.0, 36.0, 136.0, 136.0)));
// And back from page 1.
s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0)));
// Page 4 sits alone left of the spine: the object stays right of it, off the page.
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 2})), (SpreadRef::Doc(2), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0)));
}
/// A single-sided page has no spine: an object keeps its distance from the page's left edge,
/// whatever the page widths, and pastes as right-hand content into a facing-pages document.
#[test]
fn paste_in_place_from_a_single_sided_page_measures_from_its_left_edge() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
s.execute("layout.pageSize", &json!({"pages": [2], "width": 400, "height": 792})).unwrap();
let a = s.execute("frame.create", &json!({"spread": 1, "rect": [36, 36, 136, 136]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 0})), (SpreadRef::Doc(0), Rect::new(36.0, 36.0, 136.0, 136.0)));
s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap();
let w = s.doc().unwrap().doc.settings.page_width;
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({})), (SpreadRef::Doc(0), Rect::new(36.0, 36.0, 136.0, 136.0)));
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0)));
}
/// A copied text frame's clipping holds the files its anchored graphics show.
#[test]
fn copied_text_frames_keep_the_graphics_anchored_in_their_text() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let png = designcraft_render::Rendered { width: 4, height: 4, pixels: vec![200u8; 64] }.to_png();
let placed = s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "x": 72, "y": 72})).unwrap();
let (g, aid) = (ItemId(placed["id"].as_u64().unwrap()), designcraft_doc::AssetId(placed["asset"].as_u64().unwrap()));
let f = ItemId(s.execute("frame.create", &json!({"rect": [36, 200, 300, 400], "content": "text"})).unwrap()["id"].as_u64().unwrap());
s.edit(|d, _| {
let it = d.item(g).cloned().ok_or(designcraft_doc::DocError::NoItem(g))?;
let sid = d.item(f).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(designcraft_doc::DocError::NoItem(f))?;
super::super::anchored::anchor_items(d, sid, 0, vec![it], &Default::default())?;
Ok(())
})
.unwrap();
s.execute("selection.set", &json!({"ids": [f.0]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let clip = s.clipboard.clone().unwrap();
assert_eq!(clip.assets.keys().copied().collect::<Vec<_>>(), [aid]);
}
/// A selection can name an object that is gone (here the copy that Undo removed). Copy
/// measures from the spread of the first selected object that is still there.
#[test]
fn copy_measures_from_the_first_selected_object_that_exists() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap();
let w = s.doc().unwrap().doc.settings.page_width;
let a = s.execute("frame.create", &json!({"spread": 1, "rect": [w + 36.0, 36, w + 136.0, 136]})).unwrap()["id"].as_u64().unwrap();
s.execute("selection.set", &json!({"ids": [a]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let gone = s.execute("edit.paste", &json!({"spread": 1})).unwrap()["ids"][0].as_u64().unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().selection.items, [ItemId(a)], "Undo restores the selection from before the paste");
s.doc_mut().unwrap().selection.items = vec![ItemId(gone), ItemId(a)];
s.execute("edit.copy", &json!({})).unwrap();
assert_eq!(pasted(&mut s, "edit.pasteInPlace", json!({"spread": 1})), (SpreadRef::Doc(1), Rect::new(w + 36.0, 36.0, w + 136.0, 136.0)));
}
}
#[cfg(test)]
mod preview_tests {
use super::*;
fn text_session() -> Session {
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": "Hello"})).unwrap();
let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 5})).unwrap();
s
}
fn size(s: &mut Session) -> f64 {
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64().unwrap()
}
fn preview_size(s: &mut Session, v: f64) {
s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": v}}})).unwrap();
}
#[test]
fn previews_show_on_the_page_and_commit_as_one_undo_step() {
let mut s = text_session();
let (before, undo) = (size(&mut s), s.doc().unwrap().history.undo.len());
for v in [20.0, 30.0, 40.0] {
preview_size(&mut s, v);
assert_eq!(size(&mut s), v, "each preview replaces the last");
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step while previewing");
s.execute("edit.commitPreview", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert!(s.doc().unwrap().interaction.is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(size(&mut s), before);
}
/// A preview whose command fails closes again, so the next edit is its own undo step.
#[test]
fn a_failed_preview_leaves_no_preview_open() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": 20}}}));
assert!(r.is_err(), "nothing selected: type.char fails");
assert!(s.doc().unwrap().interaction.is_none());
let steps = s.doc().unwrap().history.undo.len();
s.execute("frame.create", &json!({"rect": [72, 72, 144, 144]})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
}
#[test]
fn a_cancelled_preview_leaves_the_document_as_it_was() {
let mut s = text_session();
let (before, undo) = (size(&mut s), s.doc().unwrap().history.undo.len());
preview_size(&mut s, 50.0);
s.execute("edit.cancelPreview", &json!({})).unwrap();
assert_eq!(size(&mut s), before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Committing with nothing open does nothing.
s.execute("edit.commitPreview", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn only_edits_are_previewed_and_another_interaction_is_left_alone() {
let mut s = text_session();
for command in ["edit.preview", "edit.undo", "type.selectionAttrs", "no.such.command"] {
assert!(s.execute("edit.preview", &json!({"label": "X", "command": command})).is_err(), "{command}");
}
assert!(s.execute("edit.preview", &json!({"command": "type.char"})).is_err(), "needs a label");
s.begin_interaction("Move").unwrap();
assert!(s.execute("edit.preview", &json!({"label": "Size", "command": "type.char", "params": {"attrs": {"size": 20}}})).is_err());
assert_eq!(s.doc().unwrap().interaction.as_ref().unwrap().label, "Move");
}
}