mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
Symbols: Edit Symbol opens the symbol in its own editing mode, and every instance follows each edit
Squashed from #5064 onto main.
This commit is contained in:
@@ -84,6 +84,7 @@ mod style;
|
||||
pub mod stylelib;
|
||||
mod swatch;
|
||||
pub mod swatchlib;
|
||||
pub(crate) mod symboledit;
|
||||
pub mod symbollib;
|
||||
pub(crate) mod tabs;
|
||||
mod textedit;
|
||||
@@ -183,11 +184,15 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
|
||||
}
|
||||
/// The isolated group, layer or object in isolation mode. Pattern editing mode and opacity mask
|
||||
/// editing isolate their own layer, which does not count here; an object isolated inside them
|
||||
/// counts.
|
||||
/// The isolated group, layer or object in isolation mode. Pattern editing mode, opacity mask
|
||||
/// editing and symbol editing mode isolate their own layer, which does not count here; an object
|
||||
/// isolated inside them counts.
|
||||
pub(crate) fn ordinary_isolation(st: &crate::DocState) -> Option<vectorcraft_doc::NodeId> {
|
||||
st.isolation.filter(|l| !st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l) && !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l))
|
||||
st.isolation.filter(|l| {
|
||||
!st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l)
|
||||
&& !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l)
|
||||
&& !st.doc.symbol_edit.as_ref().is_some_and(|e| e.layer == *l)
|
||||
})
|
||||
}
|
||||
/// Objects are selected ([`has_selection`]), and in isolation mode ([`ordinary_isolation`]) at
|
||||
/// least one of them is inside the isolated object: Object › Lock's and Object › Hide's items.
|
||||
|
||||
@@ -35,10 +35,10 @@ use brushes::{
|
||||
};
|
||||
use dynamic::{symbol_instance_reset, symbol_instance_set_appearance};
|
||||
use symbols::selected_instances;
|
||||
pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol};
|
||||
pub(crate) use symbols::{fit_instances_of, instance_in_place, instances_of, natural_size, normalize, place_xf, set_natural_size, store_symbol};
|
||||
use symbols::{
|
||||
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place,
|
||||
symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
|
||||
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace,
|
||||
symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
|
||||
};
|
||||
|
||||
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
|
||||
@@ -210,9 +210,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Symbol",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?: [id…] (instead of id)} break the link of the selected instance so the symbol's art can be edited (without the instance's own appearance changes to a dynamic symbol); finish with symbol.update → {name, ids}",
|
||||
has_selection,
|
||||
symbol_edit
|
||||
"{id?, ids?: [id…] (instead of id), name?, x?, y?, artboard?: index=0 (the active artboard; the app passes it), viewCenter?: [x, y] (the app passes the view's center)} edit a symbol's art in symbol editing mode: the instance `id` (default: the selected instances, which must share one symbol) where it shows the art, else the symbol `name` (default: the current symbol) centered on (x, y) (default: the center of artboard `artboard`, or of the first artboard when there is no such artboard; in a document without artboards, viewCenter, else (0, 0)); every instance follows each edit and the instance stays linked (symbol.breakLink expands one); refused while a pattern or an opacity mask is edited → {name, layer, id?}",
|
||||
has_doc,
|
||||
super::symboledit::enter
|
||||
),
|
||||
cmd!(
|
||||
"symbol.exitEditing",
|
||||
"Exit Symbol Editing Mode",
|
||||
[],
|
||||
None,
|
||||
"{} leave symbol editing mode; after an edit, select the instance it was entered from; leaving without having changed the art adds no undo step and restores the selection from before entry (object.exitIsolation also leaves it) → {name?}",
|
||||
has_doc,
|
||||
super::symboledit::leave
|
||||
),
|
||||
cmd!(
|
||||
"symbol.update",
|
||||
|
||||
@@ -95,7 +95,14 @@ pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> {
|
||||
Ok((art, (w, h), c))
|
||||
}
|
||||
|
||||
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
/// After the art of symbol `name` changed: each of its instances keeps its appearance changes to
|
||||
/// the objects the art still has.
|
||||
pub(crate) fn fit_instances_of(d: &mut Document, name: &str) {
|
||||
let ids = instances_of(d, Some(name));
|
||||
fit_overrides(d, &ids);
|
||||
}
|
||||
|
||||
pub(crate) fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
|
||||
@@ -274,6 +281,14 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
|
||||
}
|
||||
// Symbol editing mode follows the new name, and so does its layer.
|
||||
if let Some(e) = d.symbol_edit.as_mut().filter(|e| e.symbol == name) {
|
||||
e.symbol = n.clone();
|
||||
let layer = e.layer;
|
||||
if let Some(l) = d.node_mut(layer) {
|
||||
l.name = Some(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = reg {
|
||||
d.set_symbol_registration(&final_name, r);
|
||||
@@ -398,34 +413,35 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&out) }))
|
||||
}
|
||||
|
||||
pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_instances(s, p)?;
|
||||
let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?;
|
||||
let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) {
|
||||
Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(),
|
||||
_ => return Err(EngineError::Other("select a symbol instance".into())),
|
||||
};
|
||||
// The symbol's own art: one instance's appearance changes would become every instance's.
|
||||
let out = s.edit("Edit Symbol", |d, sel| {
|
||||
let out = break_links(d, &[first], false)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "name": name, "ids": ids_json(&out) }))
|
||||
}
|
||||
|
||||
pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.update";
|
||||
let name = name_param(p, C)?.to_string();
|
||||
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
|
||||
return Err(bad(C, format!("no symbol named `{name}`")));
|
||||
}
|
||||
// Redefining the symbol being edited leaves symbol editing mode in the same step.
|
||||
let session = s.doc()?.doc.symbol_edit.clone().filter(|e| e.symbol == name);
|
||||
if session.is_some() {
|
||||
crate::cmd::settle_interaction(s)?;
|
||||
s.end_text_edit();
|
||||
}
|
||||
let ids = roots(s, p)?;
|
||||
// The art on the editing layer can't redefine it: the symbol would hold an instance of itself.
|
||||
if let Some(se) = &session {
|
||||
let d = &s.doc()?.doc;
|
||||
if ids.iter().any(|i| d.ancestry(*i).is_some_and(|a| a.contains(&se.layer))) {
|
||||
return Err(EngineError::Other("a symbol can't hold an instance of itself".into()));
|
||||
}
|
||||
}
|
||||
let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art);
|
||||
let (art, size, c) = normalize(art)?;
|
||||
let old = natural_size(&s.doc()?.doc, &name);
|
||||
let editing = session.is_some();
|
||||
let id = s.edit("Redefine Symbol", |d, sel| {
|
||||
if editing {
|
||||
crate::cmd::symboledit::finish(d, sel);
|
||||
}
|
||||
let art = d.reid(&art);
|
||||
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
|
||||
sym.art = Arc::new(art);
|
||||
@@ -445,6 +461,9 @@ pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
if let Some(se) = &session {
|
||||
crate::cmd::symboledit::leave_editing(s, se)?;
|
||||
}
|
||||
Ok(json!({ "name": name, "id": id.0 }))
|
||||
}
|
||||
|
||||
@@ -476,8 +495,18 @@ pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, format!("no symbol named `{name}`")));
|
||||
}
|
||||
let expand = switch_or(p, "expandInstances", true, C)?;
|
||||
// Deleting the symbol being edited leaves symbol editing mode in the same step.
|
||||
let session = s.doc()?.doc.symbol_edit.clone().filter(|e| names.contains(&e.symbol));
|
||||
let editing = session.is_some();
|
||||
if editing {
|
||||
crate::cmd::settle_interaction(s)?;
|
||||
s.end_text_edit();
|
||||
}
|
||||
let label = if names.len() == 1 { "Delete Symbol" } else { "Delete Symbols" };
|
||||
s.edit(label, |d, _| {
|
||||
s.edit(label, |d, sel| {
|
||||
if editing {
|
||||
crate::cmd::symboledit::finish(d, sel);
|
||||
}
|
||||
// Every instance goes before any definition, whatever the order of `names`: expanding an
|
||||
// instance of one symbol can bring out instances of another being deleted (nested in its
|
||||
// art), which expand in turn while their definitions are still there. Each round expands
|
||||
@@ -519,6 +548,9 @@ pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(se) = &session {
|
||||
crate::cmd::symboledit::leave_editing(s, se)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -481,6 +481,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let label = e.label.clone();
|
||||
let costs = e.costs();
|
||||
let editing = st.doc.mask_edit.map(|m| m.layer);
|
||||
let editing_symbol = st.doc.symbol_edit.clone();
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.redo.last());
|
||||
@@ -494,6 +495,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
super::symboledit::follow_history(st, editing_symbol.as_ref());
|
||||
leave_isolation_if_gone(st);
|
||||
super::globaledit::follow_history(st);
|
||||
Ok(json!({ "undone": label }))
|
||||
@@ -507,6 +509,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let label = e.label.clone();
|
||||
let costs = e.costs();
|
||||
let editing = st.doc.mask_edit.map(|m| m.layer);
|
||||
let editing_symbol = st.doc.symbol_edit.clone();
|
||||
let doc = std::mem::replace(&mut st.doc, e.doc);
|
||||
let selection = std::mem::replace(&mut st.selection, Arc::unwrap_or_clone(e.selection));
|
||||
let selection = shared_selection(selection, st.history.undo.last());
|
||||
@@ -519,6 +522,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
super::symboledit::follow_history(st, editing_symbol.as_ref());
|
||||
leave_isolation_if_gone(st);
|
||||
super::globaledit::follow_history(st);
|
||||
Ok(json!({ "redone": label }))
|
||||
|
||||
@@ -90,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Batch",
|
||||
[],
|
||||
None,
|
||||
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
|
||||
"{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing, select.allOnArtboard or symbol.edit step that names none gets it, and each artboard.fitToArt or artboard.fitToSelection step that names no index gets it as index, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included",
|
||||
has_doc,
|
||||
batch::batch
|
||||
),
|
||||
|
||||
@@ -334,13 +334,27 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Opacity mask editing and pattern editing isolate their editing layer. Leaving isolation mode
|
||||
// then leaves that mode, as Stop Editing Opacity Mask and the isolation bar's Done do.
|
||||
// Opacity mask editing, pattern editing and symbol editing isolate their editing layer. Leaving
|
||||
// isolation mode then leaves that mode, as Stop Editing Opacity Mask and the isolation bar's
|
||||
// Done do. A group isolated inside the symbol's art goes back to the symbol's layer.
|
||||
let st = s.doc()?;
|
||||
if let Some(layer) = st.doc.symbol_edit.as_ref().map(|e| e.layer)
|
||||
&& let Some(inner) = st.isolation.filter(|i| *i != layer)
|
||||
{
|
||||
crate::cmd::settle_interaction(s)?;
|
||||
let st = s.doc_mut()?;
|
||||
crate::cmd::distortcmds::finish_edit_text(st, inner);
|
||||
st.isolation = Some(layer);
|
||||
st.selection.set([inner]);
|
||||
st.revision += 1;
|
||||
return ok();
|
||||
}
|
||||
let leave_mode: Option<fn(&mut Session, &Value) -> Result<Value>> = if st.doc.mask_edit.is_some() {
|
||||
Some(crate::cmd::maskedit::leave)
|
||||
} else if st.doc.pattern_edit.is_some() {
|
||||
Some(crate::cmd::patterncmds::pattern_done)
|
||||
} else if st.doc.symbol_edit.is_some() {
|
||||
Some(crate::cmd::symboledit::leave)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -161,12 +161,18 @@ fn in_pattern_edit(s: &Session) -> std::result::Result<(), String> {
|
||||
editing(s).map(|_| ()).ok_or_else(|| "not in pattern editing mode".into())
|
||||
}
|
||||
|
||||
/// Make: a selection, and no opacity mask is being edited
|
||||
/// ([`super::maskedit::outside_mask_editing`]), since the tile layer would replace the mask editing
|
||||
/// layer as the isolated layer.
|
||||
/// Make: a selection, and no opacity mask or symbol is being edited
|
||||
/// ([`super::maskedit::outside_mask_editing`], [`outside_symbol_editing`]), since the tile layer
|
||||
/// would replace that mode's editing layer as the isolated layer.
|
||||
fn can_make_pattern(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
super::maskedit::outside_mask_editing(s)
|
||||
super::maskedit::outside_mask_editing(s)?;
|
||||
outside_symbol_editing(s)
|
||||
}
|
||||
|
||||
/// No symbol is being edited (symbol editing mode shows the symbol in isolation mode).
|
||||
fn outside_symbol_editing(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|st| st.doc.symbol_edit.is_some()) { Err("exit isolation mode first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -175,6 +181,7 @@ fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> {
|
||||
return Err("already editing a pattern".into());
|
||||
}
|
||||
super::maskedit::outside_mask_editing(s)?;
|
||||
outside_symbol_editing(s)?;
|
||||
if s.active().is_some_and(|st| st.doc.patterns.is_empty()) { Err("the document has no patterns".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -773,9 +773,10 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"id": target.0}))
|
||||
}
|
||||
/// The layer an opacity mask or a pattern is edited in, which stays isolated while that mode is on.
|
||||
/// The layer an opacity mask, a pattern or a symbol is edited in, which stays isolated while that
|
||||
/// mode is on.
|
||||
fn editing_layer(d: &Document) -> Option<NodeId> {
|
||||
d.mask_edit.map(|m| m.layer).or_else(|| d.pattern_edit.as_ref().map(|e| e.layer))
|
||||
d.mask_edit.map(|m| m.layer).or_else(|| d.pattern_edit.as_ref().map(|e| e.layer)).or_else(|| d.symbol_edit.as_ref().map(|e| e.layer))
|
||||
}
|
||||
/// Leave isolation mode, Construction mode included. While an opacity mask or a pattern is edited,
|
||||
/// its editing layer stays isolated.
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Symbol editing mode (Edit Symbol, a double-click on an instance or on a symbol in the Symbols
|
||||
//! panel).
|
||||
//!
|
||||
//! The symbol's art moves onto a temporary isolated layer, placed where the instance it was
|
||||
//! entered from shows it (from the panel: at the center of the active artboard, or of the view in a
|
||||
//! document without artboards), where every tool and command can edit it while the rest of the
|
||||
//! document is dimmed. After each edit [`sync`] writes the layer's art back into the symbol, so
|
||||
//! every instance updates live. Leaving the mode
|
||||
//! (`symbol.exitEditing`, Escape, the isolation bar's back button, a double-click outside the art)
|
||||
//! removes the layer, and saving or exporting never writes it ([`Document::without_edit_modes`]).
|
||||
//! Symbol editing is not entered while a pattern or an opacity mask is edited, and neither of those
|
||||
//! is entered while a symbol is edited.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, SymbolRegistration};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use super::brushsym::{natural_size, place_xf};
|
||||
use super::{EngineError, Result, Session, bad, id_param, number_or, ok, str_param};
|
||||
|
||||
/// The name of the undo step that enters symbol editing mode.
|
||||
const ENTER: &str = "Edit Symbol";
|
||||
|
||||
/// The symbol instances among the selected objects, members of selected groups included.
|
||||
fn selected_instances(st: &crate::DocState) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for id in &st.selection.objects {
|
||||
if let Some(n) = st.doc.node(*id) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::SymbolInstance { .. }) && !out.contains(&c.id) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The symbol and transform of instance `id`.
|
||||
fn instance_of(d: &Document, id: NodeId) -> Option<(String, Affine)> {
|
||||
match &d.node(id)?.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => Some((symbol.clone(), *xf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where editing places the art: the instance's own transform, or for one that flattens the art,
|
||||
/// a placement at the symbol's natural size around the same point.
|
||||
fn editing_xf(d: &Document, name: &str, xf: Affine) -> Affine {
|
||||
let det = xf.determinant();
|
||||
if det.is_finite() && det.abs() > 1e-12 { xf } else { place_xf(xf * Point::ZERO, natural_size(d, name), SymbolRegistration::CENTER) }
|
||||
}
|
||||
|
||||
/// Enter symbol editing mode (`symbol.edit`).
|
||||
pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.edit";
|
||||
let st = s.doc()?;
|
||||
if st.doc.mask_edit.is_some() {
|
||||
return Err(bad(C, "stop editing the opacity mask first"));
|
||||
}
|
||||
if st.doc.pattern_edit.is_some() {
|
||||
return Err(bad(C, "finish editing the pattern first"));
|
||||
}
|
||||
let given = id_param(p, "id").map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
|
||||
let (name, xf, instance) = match given {
|
||||
Some(ids) => {
|
||||
let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::<Option<Vec<_>>>();
|
||||
let symbols = symbols.ok_or_else(|| bad(C, "select a symbol instance"))?;
|
||||
let (name, xf) = symbols.first().cloned().ok_or_else(|| bad(C, "select a symbol instance"))?;
|
||||
if symbols.iter().any(|(n, _)| *n != name) {
|
||||
return Err(bad(C, "select instances of one symbol"));
|
||||
}
|
||||
let xf = editing_xf(&st.doc, &name, xf);
|
||||
(name, xf, ids.first().copied())
|
||||
}
|
||||
None => {
|
||||
let picked = if str_param(p, "name").is_some() { vec![] } else { selected_instances(st) };
|
||||
let symbols: Vec<(String, Affine)> = picked.iter().filter_map(|id| instance_of(&st.doc, *id)).collect();
|
||||
match symbols.first() {
|
||||
Some((first, xf)) => {
|
||||
if symbols.iter().any(|(n, _)| n != first) {
|
||||
return Err(bad(C, "select instances of one symbol"));
|
||||
}
|
||||
(first.clone(), editing_xf(&st.doc, first, *xf), picked.first().copied())
|
||||
}
|
||||
None => {
|
||||
let d = &st.doc;
|
||||
let name = match str_param(p, "name") {
|
||||
Some(n) if d.symbols.iter().any(|x| x.name == n) => n.to_string(),
|
||||
Some(n) => return Err(bad(C, format!("no symbol named `{n}`"))),
|
||||
None => d
|
||||
.unknown
|
||||
.get(super::brushsym::CURRENT_SYMBOL)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|n| d.symbols.iter().any(|x| x.name == *n))
|
||||
.or_else(|| d.symbols.first().map(|x| x.name.as_str()))
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| EngineError::Other("the document has no symbols".into()))?,
|
||||
};
|
||||
let artboard = p.get("artboard").and_then(Value::as_u64).map_or(0, super::index);
|
||||
let center = match d.artboards.get(artboard).or(d.artboards.first()) {
|
||||
Some(a) => a.rect.center(),
|
||||
None => super::point_param(p, "viewCenter").unwrap_or(Point::ZERO),
|
||||
};
|
||||
let at = Point::new(number_or(p, "x", center.x, C)?, number_or(p, "y", center.y, C)?);
|
||||
(name.clone(), place_xf(at, natural_size(d, &name), SymbolRegistration::CENTER), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Editing another symbol (one from the Symbols panel, or through the API): the current edit
|
||||
// ends first.
|
||||
if st.doc.symbol_edit.is_some() {
|
||||
if instance.is_some_and(|i| st.doc.symbol_edit.as_ref().is_some_and(|e| st.doc.ancestry(i).unwrap_or_default().contains(&e.layer))) {
|
||||
return Err(bad(C, "exit isolation mode first"));
|
||||
}
|
||||
leave(s, &json!({}))?;
|
||||
}
|
||||
if let Some(i) = s.doc()?.isolation {
|
||||
super::settle_interaction(s)?;
|
||||
super::distortcmds::finish_edit_text(s.doc_mut()?, i);
|
||||
}
|
||||
s.end_text_edit();
|
||||
let st = s.doc()?;
|
||||
let back = Return { redo: st.history.redo.clone(), before: Some(st.doc.clone()) };
|
||||
let (isolation, active_layer) = (st.isolation, st.active_layer);
|
||||
let layer = s.edit(ENTER, |d, sel| {
|
||||
let layer = begin(d, sel, &name, xf, instance)?;
|
||||
if let Some(e) = d.symbol_edit.as_mut() {
|
||||
e.back_isolation = isolation;
|
||||
e.back_layer = active_layer;
|
||||
}
|
||||
Ok(layer)
|
||||
})?;
|
||||
s.doc_mut()?.symbol_edit_return = back;
|
||||
show_editing(s, layer)?;
|
||||
let mut out = json!({ "name": name, "layer": layer.0 });
|
||||
if let Some(i) = instance {
|
||||
out["id"] = json!(i.0);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A group whose members editing spreads out on the layer: nothing of its own that they would lose
|
||||
/// while they are edited.
|
||||
fn spreads(art: &Node) -> bool {
|
||||
matches!(art.kind, NodeKind::Group { clip: false, .. })
|
||||
&& art.has_default_transparency()
|
||||
&& art.appearance.items.is_empty()
|
||||
&& art.appearance.effects.is_empty()
|
||||
}
|
||||
|
||||
/// Enter symbol editing for `name` inside an edit: copy its art, placed by `xf`, onto a new
|
||||
/// editing layer and select it. Returns the layer.
|
||||
fn begin(d: &mut Document, sel: &mut Selection, name: &str, xf: Affine, instance: Option<NodeId>) -> Result<NodeId> {
|
||||
let art =
|
||||
d.symbols.iter().find(|x| x.name == name).map(|x| x.art.clone()).ok_or_else(|| EngineError::Other(format!("no symbol named `{name}`")))?;
|
||||
let group = spreads(&art);
|
||||
let parts = match art.children() {
|
||||
Some(ch) if group => ch.clone(),
|
||||
_ => vec![art],
|
||||
};
|
||||
let layer = d.add_layer(Some(name));
|
||||
let mut ids = Vec::with_capacity(parts.len());
|
||||
for part in &parts {
|
||||
let mut copy = d.reid(part);
|
||||
copy.transform(xf, false);
|
||||
ids.push(d.insert(Some(layer), usize::MAX, copy)?);
|
||||
}
|
||||
// Copied type whose layout caches `reid` dropped has them again.
|
||||
super::typecmd::refresh_bounds_of(d, &ids);
|
||||
d.symbol_edit = Some(SymbolEdit { symbol: name.to_string(), layer, instance, xf, group, changed: false, back_isolation: None, back_layer: None });
|
||||
sel.set(ids);
|
||||
Ok(layer)
|
||||
}
|
||||
|
||||
/// Leave symbol editing mode (`symbol.exitEditing`); a no-op outside it. `object.exitIsolation` runs
|
||||
/// it too while a symbol is edited. Isolation and the current layer go back to what they were
|
||||
/// before. When nothing changed the art since editing was entered, the entering step is taken back
|
||||
/// instead of a step being added, so the document and its redo steps are as they were.
|
||||
pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some(se) = s.doc()?.doc.symbol_edit.clone() else { return ok() };
|
||||
super::settle_interaction(s)?;
|
||||
super::distortcmds::finish_edit_text(s.doc_mut()?, se.layer);
|
||||
s.end_text_edit();
|
||||
let st = s.doc_mut()?;
|
||||
let unchanged = !st.doc.symbol_edit.as_ref().is_some_and(|e| e.changed);
|
||||
let Return { redo, before } = std::mem::take(&mut st.symbol_edit_return);
|
||||
// Saved while editing: the saved file holds no editing layer, so leaving keeps it saved.
|
||||
let saved_here = st.saved_doc.symbol_edit.as_ref().is_some_and(|e| e.layer == se.layer);
|
||||
let clean = !st.is_dirty();
|
||||
// Not after edits that were undone (their redo steps hold the editing layer), nor after a save
|
||||
// (taking the entering step back would undo what the save changed). The last step is the
|
||||
// entering step only when it starts from the document entering started from: a batch step
|
||||
// that entered after other edits starts from before them.
|
||||
if unchanged
|
||||
&& !saved_here
|
||||
&& st.history.redo.is_empty()
|
||||
&& let Some(entry) = st.history.undo.last()
|
||||
&& before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &entry.doc))
|
||||
&& let Some(entry) = st.history.undo.pop()
|
||||
{
|
||||
let now = std::mem::replace(&mut st.doc, entry.doc);
|
||||
// What changed outside the undo history while editing (the current symbol and brush, the
|
||||
// perspective grid's view, export settings, the grid preferences) stays as it is now.
|
||||
if st.doc.unknown != now.unknown || st.doc.export_settings != now.export_settings || st.doc.grid != now.grid {
|
||||
let d = Arc::make_mut(&mut st.doc);
|
||||
d.unknown = now.unknown.clone();
|
||||
d.export_settings = now.export_settings.clone();
|
||||
d.grid = now.grid.clone();
|
||||
}
|
||||
st.selection = Arc::unwrap_or_clone(entry.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
st.history.redo = redo;
|
||||
st.revision += 1;
|
||||
} else {
|
||||
s.edit("Exit Symbol Editing Mode", |d, sel| {
|
||||
finish(d, sel);
|
||||
Ok(())
|
||||
})?;
|
||||
if saved_here && clean {
|
||||
s.doc_mut()?.mark_saved();
|
||||
}
|
||||
}
|
||||
leave_editing(s, &se)?;
|
||||
Ok(json!({ "name": se.symbol }))
|
||||
}
|
||||
|
||||
/// Leave symbol editing inside an edit (no-op when not editing): drop the editing layer and select
|
||||
/// the instance editing was entered from. The symbol already holds the layer's art ([`sync`]).
|
||||
pub(crate) fn finish(d: &mut Document, sel: &mut Selection) {
|
||||
let Some(se) = d.symbol_edit.take() else { return };
|
||||
let _ = d.remove(se.layer);
|
||||
match se.instance.filter(|i| d.node(*i).is_some()) {
|
||||
Some(i) => sel.set([i]),
|
||||
None => sel.clear(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The redo steps entering symbol editing mode cleared, which leaving without an edit gives back,
|
||||
/// and the document entering started from, which tells the entering step apart from any other.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Return {
|
||||
redo: Vec<crate::HistoryEntry>,
|
||||
before: Option<Arc<Document>>,
|
||||
}
|
||||
|
||||
/// Isolate the editing layer `layer` and draw into it.
|
||||
fn show_editing(s: &mut Session, layer: NodeId) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.isolation = Some(layer);
|
||||
st.active_layer = Some(layer);
|
||||
st.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return to the document after leaving the editing session `se`: to the isolation and current
|
||||
/// layer from before it, while they are still there.
|
||||
pub(crate) fn leave_editing(s: &mut Session, se: &SymbolEdit) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.symbol_edit_return = Return::default();
|
||||
go_back(st, se);
|
||||
st.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Out of the editing session `se`: the isolation and current layer from before it, else none and
|
||||
/// the default layer.
|
||||
pub(crate) fn go_back(st: &mut crate::DocState, se: &SymbolEdit) {
|
||||
st.isolation = se.back_isolation.filter(|i| st.doc.node(*i).is_some());
|
||||
st.active_layer = se.back_layer.filter(|l| st.doc.node(*l).is_some_and(|n| n.is_layer())).or_else(|| st.doc.default_layer());
|
||||
}
|
||||
|
||||
/// After undo or redo, isolation follows the restored document into or out of symbol editing
|
||||
/// (`was` is the editing session before, if any), so it never names a layer that is gone. A group
|
||||
/// isolated inside the symbol's art stays isolated while it is there.
|
||||
pub(crate) fn follow_history(st: &mut crate::DocState, was: Option<&SymbolEdit>) {
|
||||
match st.doc.symbol_edit.as_ref().map(|e| e.layer) {
|
||||
Some(layer) => {
|
||||
let inside = st.isolation.is_some_and(|i| i != layer && st.doc.ancestry(i).is_some_and(|a| a.contains(&layer)));
|
||||
if !inside {
|
||||
st.isolation = Some(layer);
|
||||
}
|
||||
st.active_layer = Some(layer);
|
||||
}
|
||||
None => {
|
||||
// Out of the mode: isolation on the editing layer or inside it, and a current layer
|
||||
// that is gone, go back to what they were before it.
|
||||
let Some(was) = was else { return };
|
||||
let gone = |id: Option<NodeId>| id.is_some_and(|i| i == was.layer || st.doc.node(i).is_none());
|
||||
if gone(st.isolation) || gone(st.active_layer) {
|
||||
go_back(st, was);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` hold an instance of symbol `name`, directly or through the art of the symbols it holds
|
||||
/// instances of?
|
||||
fn holds_symbol(d: &Document, n: &Node, name: &str, seen: &mut HashSet<String>) -> bool {
|
||||
let mut found = false;
|
||||
let mut nested = vec![];
|
||||
n.walk(&mut |c| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
|
||||
if symbol == name {
|
||||
found = true;
|
||||
} else if !seen.contains(symbol) {
|
||||
nested.push(symbol.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if found {
|
||||
return true;
|
||||
}
|
||||
for symbol in nested {
|
||||
if seen.insert(symbol.clone())
|
||||
&& let Some(art) = d.symbols.iter().find(|x| x.name == symbol).map(|x| x.art.clone())
|
||||
&& holds_symbol(d, &art, name, seen)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The object at `path` (child indices, paint order) among `nodes` and the groups they hold.
|
||||
fn at_path<'a>(nodes: &'a [Arc<Node>], path: &[u32]) -> Option<&'a Node> {
|
||||
let (first, rest) = path.split_first()?;
|
||||
let mut n: &Node = nodes.get(*first as usize)?;
|
||||
for i in rest {
|
||||
n = n.children()?.get(*i as usize)?;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// The path of object `id` among `nodes` and the groups they hold, at most `depth` levels down.
|
||||
fn path_of(nodes: &[Arc<Node>], id: NodeId, depth: usize) -> Option<Vec<u32>> {
|
||||
if depth == 0 {
|
||||
return None;
|
||||
}
|
||||
for (i, n) in nodes.iter().enumerate() {
|
||||
let i = u32::try_from(i).ok()?;
|
||||
if n.id == id {
|
||||
return Some(vec![i]);
|
||||
}
|
||||
if let Some(mut p) = n.children().and_then(|ch| path_of(ch, id, depth - 1)) {
|
||||
p.insert(0, i);
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Each instance's appearance changes to objects of symbol `name`'s art follow those objects
|
||||
/// through an edit of the editing layer: `old` and `new` are the layer's objects before and after
|
||||
/// with whether the symbol's art is a group of them (else it is the one object). A change to an
|
||||
/// object the edit removed names no object afterwards.
|
||||
fn follow_objects(d: &mut Document, name: &str, old: (&[Arc<Node>], bool), new: (&[Arc<Node>], bool)) {
|
||||
let depth = vectorcraft_doc::symbol_overrides::MAX_PATH_DEPTH + 1;
|
||||
for id in super::brushsym::instances_of(d, Some(name)) {
|
||||
let Some(n) = d.node_mut(id) else { continue };
|
||||
if n.extras().symbol_overrides.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut e = n.extras_mut();
|
||||
e.symbol_overrides.retain_mut(|o| {
|
||||
// The art's own group stays the art's root.
|
||||
if old.1 && o.path.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let on_layer: Vec<u32> = if old.1 { o.path.clone() } else { std::iter::once(0).chain(o.path.iter().copied()).collect() };
|
||||
let moved = at_path(old.0, &on_layer).and_then(|obj| path_of(new.0, obj.id, depth)).and_then(|p| match new.1 {
|
||||
true => Some(p),
|
||||
false => p.split_first().filter(|(first, _)| **first == 0).map(|(_, rest)| rest.to_vec()),
|
||||
});
|
||||
match moved {
|
||||
Some(p) => {
|
||||
o.path = p;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the editing layer's art back into the symbol (called after every edit, with the document
|
||||
/// as it was before); each instance of a dynamic symbol keeps its appearance changes to the objects
|
||||
/// the art still has. Leaves the mode when the symbol or the layer is gone. Refuses art that holds
|
||||
/// an instance of the symbol itself.
|
||||
pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> {
|
||||
let Some(se) = d.symbol_edit.clone() else { return Ok(()) };
|
||||
let art: Option<Vec<Arc<Node>>> = d.node(se.layer).and_then(|l| l.children().cloned());
|
||||
let (Some(art), Some(index)) = (art, d.symbols.iter().position(|x| x.name == se.symbol)) else {
|
||||
d.symbol_edit = None;
|
||||
let _ = d.remove(se.layer);
|
||||
return Ok(());
|
||||
};
|
||||
// Entering, and edits that left the layer's art alone, write nothing.
|
||||
let same = |b: &[Arc<Node>]| b.len() == art.len() && b.iter().zip(&art).all(|(a, b)| Arc::ptr_eq(a, b));
|
||||
let (old, old_group) = match before.symbol_edit.as_ref().filter(|b| b.layer == se.layer) {
|
||||
None => return Ok(()),
|
||||
Some(_) if before.node(se.layer).and_then(|l| l.children()).is_some_and(|b| same(b)) => return Ok(()),
|
||||
Some(b) => (before.node(se.layer).and_then(|l| l.children().cloned()).unwrap_or_default(), b.group),
|
||||
};
|
||||
if art.iter().any(|n| holds_symbol(d, n, &se.symbol, &mut HashSet::new())) {
|
||||
return Err(EngineError::Other("a symbol can't hold an instance of itself".into()));
|
||||
}
|
||||
let inv = se.xf.inverse();
|
||||
let parts: Vec<Arc<Node>> = art
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let mut n = (**n).clone();
|
||||
n.transform(inv, false);
|
||||
Arc::new(n)
|
||||
})
|
||||
.collect();
|
||||
// Once the layer's objects make a group, later edits give that group its members.
|
||||
let (new_art, group) = if se.group {
|
||||
let mut g = (*d.symbols[index].art).clone();
|
||||
if let Some(ch) = g.children_mut() {
|
||||
*ch = parts;
|
||||
}
|
||||
(g, true)
|
||||
} else {
|
||||
match <[Arc<Node>; 1]>::try_from(parts) {
|
||||
Ok([one]) => (Arc::unwrap_or_clone(one), false),
|
||||
Err(parts) => {
|
||||
let id = d.alloc_id();
|
||||
(Node::group(id, parts), true)
|
||||
}
|
||||
}
|
||||
};
|
||||
d.symbols[index].art = Arc::new(new_art);
|
||||
follow_objects(d, &se.symbol, (&old, old_group), (&art, group));
|
||||
super::brushsym::fit_instances_of(d, &se.symbol);
|
||||
if let Some(e) = d.symbol_edit.as_mut() {
|
||||
e.changed = true;
|
||||
e.group = group;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,10 +2,10 @@ use super::{NodeId, Value, cmd};
|
||||
|
||||
/// The commands that act on the active artboard (#693), which the app passes as their `artboard`:
|
||||
/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute
|
||||
/// Objects and Distribute Spacing with Align to Artboard distribute objects across it, and All on
|
||||
/// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their
|
||||
/// steps that names none.
|
||||
pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [
|
||||
/// Objects and Distribute Spacing with Align to Artboard distribute objects across it, All on
|
||||
/// Active Artboard selects on it, and Edit Symbol from the Symbols panel centers the symbol's art
|
||||
/// on it. A `command.batch` given `artboard` passes it on to each of their steps that names none.
|
||||
pub const ON_ACTIVE_ARTBOARD: [&str; 8] = [
|
||||
"edit.pasteInPlace",
|
||||
"edit.pasteInFront",
|
||||
"edit.pasteInBack",
|
||||
@@ -13,6 +13,7 @@ pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [
|
||||
"object.distribute",
|
||||
"object.distributeSpacing",
|
||||
"select.allOnArtboard",
|
||||
"symbol.edit",
|
||||
];
|
||||
|
||||
/// The commands that resize the artboards the app passes (Object › Artboards): the app passes the
|
||||
@@ -23,9 +24,9 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
|
||||
|
||||
/// `(command, param)` pairs the journal records only so a replay reproduces the original run
|
||||
/// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves
|
||||
/// them out ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 14] = [
|
||||
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the view's center
|
||||
/// the app passes to Edit Symbol); an action leaves them out ([`Session::journal_for_action`]).
|
||||
const REPLAY_ONLY: [(&str, &str); 16] = [
|
||||
("file.new", "created"),
|
||||
("document.save", "modified"),
|
||||
("file.saveAs", "modified"),
|
||||
@@ -39,6 +40,8 @@ const REPLAY_ONLY: [(&str, &str); 14] = [
|
||||
("artboard.fitToArt", "indices"),
|
||||
("artboard.fitToSelection", "index"),
|
||||
("select.allOnArtboard", "artboard"),
|
||||
("symbol.edit", "artboard"),
|
||||
("symbol.edit", "viewCenter"),
|
||||
("command.batch", "artboard"),
|
||||
];
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct DocState {
|
||||
/// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape
|
||||
/// ([`cmd::globaledit`]). Editing state: not saved, not undoable.
|
||||
pub global_edit: Option<cmd::globaledit::GlobalEdit>,
|
||||
/// What leaving symbol editing mode goes back to ([`cmd::symboledit`]).
|
||||
pub(crate) symbol_edit_return: cmd::symboledit::Return,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Edits made while this is open are one undo step (a scrubbed numeric field).
|
||||
pub undo_group: Option<UndoGroup>,
|
||||
@@ -246,6 +248,7 @@ impl DocState {
|
||||
variables_highlight: None,
|
||||
isolation: None,
|
||||
global_edit: None,
|
||||
symbol_edit_return: Default::default(),
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
last_transform: None,
|
||||
|
||||
@@ -37,6 +37,10 @@ impl Session {
|
||||
if shaper_changed || dim_changed {
|
||||
cmd::objects_on_path::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
}
|
||||
// Symbol editing: the symbol follows its art on the editing layer, before
|
||||
// inline graphics follow their symbols.
|
||||
let symbol_synced =
|
||||
if st.doc.symbol_edit.is_some() { cmd::symboledit::sync(&before, Arc::make_mut(&mut st.doc)) } else { Ok(()) };
|
||||
// Inline graphics in text follow their symbols.
|
||||
cmd::inline::refresh(&before, Arc::make_mut(&mut st.doc));
|
||||
// Text Wrap: area type follows its wrap objects; then threads re-flow.
|
||||
@@ -60,7 +64,9 @@ impl Session {
|
||||
if !st.doc.variables.bindings.is_empty() {
|
||||
Arc::make_mut(&mut st.doc).prune_variable_bindings();
|
||||
}
|
||||
if st.doc.too_deep(Some(&before)) {
|
||||
if let Err(e) = symbol_synced {
|
||||
Err(e)
|
||||
} else if st.doc.too_deep(Some(&before)) {
|
||||
// Deeper trees overflow the stack of what walks them.
|
||||
Err(EngineError::Other(TOO_DEEP.into()))
|
||||
} else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) {
|
||||
@@ -82,6 +88,21 @@ impl Session {
|
||||
// The art on a locked artboard can't stay selected, however it got there, except
|
||||
// the new art this edit made and new art still selected.
|
||||
st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before));
|
||||
// Symbol or opacity mask editing ended because the edit deleted its layer:
|
||||
// isolation on the layer or inside it leaves too, out of symbol editing to what it
|
||||
// was before.
|
||||
let mode_layer = before.symbol_edit.as_ref().map(|e| e.layer).or(before.mask_edit.map(|m| m.layer));
|
||||
if let Some(layer) = mode_layer
|
||||
&& st.doc.node(layer).is_none()
|
||||
&& st.isolation.is_some_and(|i| i == layer || st.doc.node(i).is_none())
|
||||
{
|
||||
if let Some(se) = &before.symbol_edit {
|
||||
cmd::symboledit::go_back(st, se);
|
||||
} else {
|
||||
st.isolation = None;
|
||||
st.active_layer = st.doc.default_layer();
|
||||
}
|
||||
}
|
||||
st.revision += 1;
|
||||
if st.interaction.is_none() {
|
||||
let selection = match st.history.undo.last() {
|
||||
|
||||
@@ -158,6 +158,7 @@ mod swatchcmds;
|
||||
mod swatches;
|
||||
mod swatchlib;
|
||||
mod symbolbounds;
|
||||
mod symboledit;
|
||||
mod symbollib;
|
||||
mod targeting;
|
||||
mod textcombos;
|
||||
|
||||
@@ -1117,13 +1117,12 @@ fn place_and_break_link_round_trip_geometry() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_then_update_redefines_all_instances() {
|
||||
fn break_link_then_update_redefines_all_instances() {
|
||||
let mut s = session();
|
||||
let (inst, name, _) = make_symbol(&mut s);
|
||||
let other = NodeId(s.execute("symbol.place", &json!({"x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap());
|
||||
select(&mut s, &[inst]);
|
||||
let r = s.execute("symbol.edit", &json!({})).unwrap();
|
||||
assert_eq!(r["name"], name);
|
||||
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
|
||||
let art = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
// Make the art twice as wide, then redefine.
|
||||
s.execute("object.transform", &json!({"ids": [art.0], "matrix": [2, 0, 0, 1, -140, 0]})).ok();
|
||||
|
||||
@@ -144,8 +144,14 @@ fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() {
|
||||
};
|
||||
let art = NodeId(s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap()["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(fills(&s, art), ["#0000ff", "#ff0000"]);
|
||||
let art = NodeId(s.execute("symbol.edit", &json!({"ids": [other.0]})).unwrap()["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(fills(&s, art), ["#0000ff", "#0000ff"], "the symbol's art, without this instance's changes");
|
||||
// Symbol editing mode shows the symbol's art, without this instance's changes.
|
||||
s.execute("symbol.edit", &json!({"ids": [other.0]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let layer = st.doc.symbol_edit.as_ref().unwrap().layer;
|
||||
let parts: Vec<String> =
|
||||
st.doc.node(layer).unwrap().children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect();
|
||||
assert_eq!(parts, ["#0000ff", "#0000ff"], "the symbol's art, without this instance's changes");
|
||||
assert_eq!(overrides(&s, other), 1, "the instance keeps its changes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
//! Symbol editing mode (`symbol.edit`, `symbol.exitEditing`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().expect("an id"))
|
||||
}
|
||||
|
||||
/// A 400 × 300 document with the 80 × 40 symbol "Box" made from a rectangle at (100, 50), its
|
||||
/// instance there and a second instance centered on (300, 200).
|
||||
fn two_instances() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 50, "width": 80, "height": 40}));
|
||||
let first = id(&run(&mut s, "symbol.new", json!({"name": "Box"})));
|
||||
let second = id(&run(&mut s, "symbol.place", json!({"name": "Box", "x": 300, "y": 200})));
|
||||
(s, first, second)
|
||||
}
|
||||
|
||||
/// The bounds of `id` as the document measures them: an instance's by its symbol's art.
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.bounds_of(&[id], false).expect("bounds")
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
fn is_instance(s: &Session, id: NodeId) -> bool {
|
||||
s.doc().unwrap().doc.node(id).is_some_and(|n| matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Box"))
|
||||
}
|
||||
|
||||
/// The objects on the editing layer.
|
||||
fn layer_art(s: &Session) -> Vec<NodeId> {
|
||||
let st = s.doc().unwrap();
|
||||
let layer = st.doc.symbol_edit.as_ref().expect("editing a symbol").layer;
|
||||
st.doc.node(layer).and_then(|l| l.children()).map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_symbol_edits_the_art_where_the_instance_shows_it_and_every_instance_follows() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
run(&mut s, "select.set", json!({"ids": [first.0]}));
|
||||
let r = run(&mut s, "symbol.edit", json!({}));
|
||||
assert_eq!((r["name"].as_str(), r["id"].as_u64()), (Some("Box"), Some(first.0)));
|
||||
let layer = NodeId(r["layer"].as_u64().unwrap());
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.isolation, Some(layer));
|
||||
assert_eq!(st.active_layer, Some(layer));
|
||||
// The instance stays linked: editing the symbol doesn't expand it.
|
||||
assert!(is_instance(&s, first) && is_instance(&s, second));
|
||||
let art = layer_art(&s);
|
||||
assert_eq!(art.len(), 1);
|
||||
assert!(close(bounds(&s, art[0]), Rect::new(100.0, 50.0, 180.0, 90.0)), "{:?}", bounds(&s, art[0]));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, art);
|
||||
// An edit of the art reaches both instances at once, each at its own place.
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
assert!(close(bounds(&s, first), Rect::new(110.0, 50.0, 190.0, 90.0)), "{:?}", bounds(&s, first));
|
||||
assert!(close(bounds(&s, second), Rect::new(270.0, 180.0, 350.0, 220.0)), "{:?}", bounds(&s, second));
|
||||
// New art goes into the symbol.
|
||||
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 10, "height": 10}));
|
||||
assert_eq!(layer_art(&s).len(), 2);
|
||||
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 350.0, 240.0)), "{:?}", bounds(&s, second));
|
||||
let r = run(&mut s, "symbol.exitEditing", json!({}));
|
||||
assert_eq!(r["name"], "Box");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
|
||||
assert!(st.doc.node(layer).is_none(), "the editing layer is gone");
|
||||
assert_eq!(st.selection.objects, vec![first]);
|
||||
assert!(is_instance(&s, first));
|
||||
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 350.0, 240.0)), "{:?}", bounds(&s, second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_transform_places_the_art_and_edits_go_back_through_it() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
// The second instance twice as large: a 20 pt move there is a 10 pt move of the symbol's art.
|
||||
run(&mut s, "select.set", json!({"ids": [second.0]}));
|
||||
run(&mut s, "object.scale", json!({"sx": 200, "sy": 200, "origin": [300, 200]}));
|
||||
let big = bounds(&s, second);
|
||||
assert!(close(big, Rect::new(220.0, 160.0, 380.0, 240.0)), "{big:?}");
|
||||
run(&mut s, "symbol.edit", json!({"id": second.0}));
|
||||
let art = layer_art(&s);
|
||||
assert!(close(bounds(&s, art[0]), big), "{:?}", bounds(&s, art[0]));
|
||||
run(&mut s, "object.move", json!({"dx": 20, "dy": 0}));
|
||||
assert!(close(bounds(&s, second), Rect::new(240.0, 160.0, 400.0, 240.0)), "{:?}", bounds(&s, second));
|
||||
assert!(close(bounds(&s, first), Rect::new(110.0, 50.0, 190.0, 90.0)), "{:?}", bounds(&s, first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_symbols_panel_edits_a_symbol_without_an_instance_at_the_artboard_center() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
run(&mut s, "edit.clear", json!({"ids": [first.0, second.0]}));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
let r = run(&mut s, "symbol.edit", json!({"name": "Box"}));
|
||||
assert!(r.get("id").is_none());
|
||||
let art = layer_art(&s);
|
||||
assert!(close(bounds(&s, art[0]), Rect::new(160.0, 130.0, 240.0, 170.0)), "{:?}", bounds(&s, art[0]));
|
||||
run(&mut s, "object.move", json!({"dx": 0, "dy": 5}));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
// No instance was added, and a new one shows the edited art.
|
||||
let mut instances = 0;
|
||||
s.doc().unwrap().doc.walk(|n| instances += usize::from(matches!(n.kind, NodeKind::SymbolInstance { .. })));
|
||||
assert_eq!(instances, 0);
|
||||
let placed = id(&run(&mut s, "symbol.place", json!({"name": "Box", "x": 200, "y": 150})));
|
||||
assert!(close(bounds(&s, placed), Rect::new(160.0, 135.0, 240.0, 175.0)), "{:?}", bounds(&s, placed));
|
||||
assert!(s.execute("symbol.edit", &json!({"name": "Nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_symbols_panel_edits_a_symbol_at_the_center_of_the_active_artboard_or_the_view() {
|
||||
let (mut s, _, _) = two_instances();
|
||||
run(&mut s, "artboard.new", json!({"x": 500, "y": 0, "width": 200, "height": 100}));
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "symbol.edit", json!({"name": "Box", "artboard": 1}));
|
||||
let art = layer_art(&s);
|
||||
assert!(close(bounds(&s, art[0]), Rect::new(560.0, 30.0, 640.0, 70.0)), "{:?}", bounds(&s, art[0]));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
// An artboard the document doesn't have: the first one.
|
||||
run(&mut s, "symbol.edit", json!({"name": "Box", "artboard": 7, "viewCenter": [1000, 1000]}));
|
||||
let art = layer_art(&s);
|
||||
assert!(close(bounds(&s, art[0]), Rect::new(160.0, 130.0, 240.0, 170.0)), "{:?}", bounds(&s, art[0]));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
// Without artboards, the view's center.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear();
|
||||
run(&mut s, "symbol.edit", json!({"name": "Box", "viewCenter": [1000, 1000]}));
|
||||
let art = layer_art(&s);
|
||||
assert!(close(bounds(&s, art[0]), Rect::new(960.0, 980.0, 1040.0, 1020.0)), "{:?}", bounds(&s, art[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_without_an_edit_leaves_the_document_and_its_history_as_they_were() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let art = s.doc().unwrap().doc.symbols[0].art.clone();
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
run(&mut s, "object.exitIsolation", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), steps);
|
||||
assert!(std::sync::Arc::ptr_eq(&st.doc.symbols[0].art, &art));
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
|
||||
// Outside the mode, leaving it does nothing.
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
// A step undone before editing can still be redone after leaving without an edit.
|
||||
run(&mut s, "shape.rectangle", json!({"x": 300, "y": 20, "width": 10, "height": 10}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
assert!(s.doc().unwrap().history.redo.is_empty());
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
assert_eq!(s.doc().unwrap().history.redo.len(), 1);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
// Leaving after an edit that was undone adds a step, keeping the redo step that holds the
|
||||
// editing layer apart from the document outside the mode.
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_without_an_edit_keeps_the_current_symbol_picked_while_editing() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
|
||||
run(&mut s, "symbol.new", json!({"name": "Small"}));
|
||||
run(&mut s, "symbol.setCurrent", json!({"name": "Box"}));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
// A click in the Symbols panel while editing: not an undo step.
|
||||
run(&mut s, "symbol.setCurrent", json!({"name": "Small"}));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), steps);
|
||||
assert_eq!(st.doc.unknown.get("currentSymbol").and_then(Value::as_str), Some("Small"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_right_after_a_batch_that_only_entered_takes_its_step_back() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "command.batch", json!({"commands": [{"command": "symbol.edit", "params": {"id": first.0}}]}));
|
||||
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), steps);
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_right_after_a_batch_that_edited_and_entered_keeps_its_edits() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// A batch named like the entering step that moves an instance before entering: leaving keeps
|
||||
// the move and adds a step of its own.
|
||||
let moved = Rect::new(310.0, 180.0, 390.0, 220.0);
|
||||
let commands = json!([
|
||||
{"command": "select.set", "params": {"ids": [second.0]}},
|
||||
{"command": "object.move", "params": {"dx": 50, "dy": 0}},
|
||||
{"command": "symbol.edit", "params": {"id": first.0}},
|
||||
]);
|
||||
run(&mut s, "command.batch", json!({"label": "Edit Symbol", "commands": commands}));
|
||||
assert!(close(bounds(&s, second), moved), "{:?}", bounds(&s, second));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
|
||||
assert_eq!(st.history.undo.len(), steps + 2);
|
||||
assert!(close(bounds(&s, second), moved), "{:?}", bounds(&s, second));
|
||||
// Undo takes back the exit, then the batch's move and entry together.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
|
||||
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 340.0, 220.0)), "{:?}", bounds(&s, second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefining_the_symbol_being_edited_leaves_symbol_editing_mode_in_the_same_step() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 30, "height": 30})));
|
||||
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
|
||||
// The art on the editing layer can't redefine the symbol.
|
||||
let e = s.execute("symbol.update", &json!({"name": "Box"})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't hold an instance of itself"), "{e}");
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "symbol.update", json!({"name": "Box", "ids": [other.0]}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none() && st.doc.node(layer).is_none());
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
// Every instance shows the new 30 × 30 art, and a later edit can't bring the old art back.
|
||||
assert!(close(bounds(&s, second), Rect::new(285.0, 185.0, 315.0, 215.0)), "{:?}", bounds(&s, second));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 300, "y": 10, "width": 10, "height": 10}));
|
||||
assert!(close(bounds(&s, second), Rect::new(285.0, 185.0, 315.0, 215.0)), "{:?}", bounds(&s, second));
|
||||
// Undo goes back into the edit as it was.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.isolation, Some(layer));
|
||||
assert!(st.doc.symbol_edit.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_goes_back_to_the_isolation_before_and_undo_keeps_a_group_isolated_inside() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
// The instance in an isolated group.
|
||||
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20})));
|
||||
run(&mut s, "select.set", json!({"ids": [first.0, other.0]}));
|
||||
let group = id(&run(&mut s, "object.group", json!({})));
|
||||
run(&mut s, "object.isolate", json!({"id": group.0}));
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(group));
|
||||
// A group isolated inside the symbol's art stays isolated through an undo.
|
||||
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
|
||||
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 10, "height": 10}));
|
||||
let art: Vec<u64> = layer_art(&s).iter().map(|i| i.0).collect();
|
||||
run(&mut s, "select.set", json!({ "ids": art }));
|
||||
let inner = id(&run(&mut s, "object.group", json!({})));
|
||||
run(&mut s, "object.isolate", json!({"id": inner.0}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "object.move", json!({"dx": 5, "dy": 0}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(inner));
|
||||
run(&mut s, "object.exitIsolation", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(layer));
|
||||
run(&mut s, "object.exitIsolation", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(group));
|
||||
// Deleting the editing layer goes back there too.
|
||||
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
|
||||
run(&mut s, "layer.delete", json!({"ids": [layer.0]}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(group));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_after_a_save_keeps_the_document_saved() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
for edit in [false, true] {
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
if edit {
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
}
|
||||
// The save leaves the editing layer out of the file.
|
||||
s.doc_mut().unwrap().mark_saved();
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(!st.is_dirty(), "edit: {edit}");
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none(), "edit: {edit}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_editing_layer_leaves_symbol_editing_mode() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
let layer = NodeId(run(&mut s, "symbol.edit", json!({"id": first.0}))["layer"].as_u64().unwrap());
|
||||
run(&mut s, "layer.delete", json!({"ids": [layer.0]}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.doc.node(layer).is_none());
|
||||
assert_eq!(st.isolation, None);
|
||||
assert!(st.active_layer.is_some_and(|l| st.doc.node(l).is_some()));
|
||||
assert!(is_instance(&s, first));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_restore_the_art_every_instance_shows_and_the_mode() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(layer), "back in symbol editing mode");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(close(bounds(&s, second), Rect::new(260.0, 180.0, 340.0, 220.0)), "{:?}", bounds(&s, second));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(layer));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(close(bounds(&s, second), Rect::new(270.0, 180.0, 350.0, 220.0)), "{:?}", bounds(&s, second));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_of_one_symbol_only_and_no_instance_of_itself() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
|
||||
let other = id(&run(&mut s, "symbol.new", json!({"name": "Small"})));
|
||||
run(&mut s, "select.set", json!({"ids": [first.0, other.0]}));
|
||||
let e = s.execute("symbol.edit", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("select instances of one symbol"), "{e}");
|
||||
let e = s.execute("symbol.edit", &json!({"ids": [first.0, other.0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("select instances of one symbol"), "{e}");
|
||||
assert!(s.execute("symbol.edit", &json!({"ids": [first.0, 999_999]})).is_err());
|
||||
assert!(s.doc().unwrap().doc.symbol_edit.is_none());
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
let e = s.execute("symbol.place", &json!({"name": "Box"})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't hold an instance of itself"), "{e}");
|
||||
assert_eq!(layer_art(&s).len(), 1);
|
||||
// An instance of another symbol may go in.
|
||||
run(&mut s, "symbol.place", json!({"name": "Small"}));
|
||||
assert_eq!(layer_art(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_editing_modes_and_symbol_editing_do_not_overlap() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
assert!(s.execute("object.pattern.make", &json!({})).is_err());
|
||||
assert!(s.execute("transparency.editOpacityMask", &json!({})).is_err());
|
||||
// Editing another symbol from the panel ends this edit first.
|
||||
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
run(&mut s, "select.set", json!({"ids": [second.0]}));
|
||||
run(&mut s, "symbol.edit", json!({}));
|
||||
run(&mut s, "symbol.edit", json!({"name": "Box", "x": 50, "y": 50}));
|
||||
assert_eq!(s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().instance, None);
|
||||
assert_eq!(s.doc().unwrap().doc.layers.iter().filter(|l| l.name.as_deref() == Some("Box")).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_deleting_and_saving_while_a_symbol_is_edited() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
|
||||
// Saving leaves the editing layer out; the symbol already holds the edit.
|
||||
let saved = s.doc().unwrap().doc.without_edit_modes().into_owned();
|
||||
assert!(saved.symbol_edit.is_none() && saved.node(layer).is_none());
|
||||
assert!(saved.symbols[0].art.geometric_bounds().is_some());
|
||||
run(&mut s, "symbol.options", json!({"name": "Box", "newName": "Crate"}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.symbol_edit.as_ref().unwrap().symbol, "Crate");
|
||||
assert_eq!(st.doc.node(layer).and_then(|l| l.name.clone()).as_deref(), Some("Crate"));
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
// Deleting the symbol being edited leaves the mode in the same step.
|
||||
run(&mut s, "symbol.delete", json!({"name": "Crate"}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none() && st.doc.node(layer).is_none());
|
||||
assert!(st.doc.symbols.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(layer));
|
||||
}
|
||||
|
||||
/// Deleting several symbols, the one being edited among them, leaves the mode in the same step.
|
||||
#[test]
|
||||
fn deleting_several_symbols_with_the_edited_one_leaves_the_mode() {
|
||||
let (mut s, first, _) = two_instances();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20}));
|
||||
run(&mut s, "symbol.new", json!({"name": "Dot"}));
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
|
||||
run(&mut s, "symbol.delete", json!({"names": ["Dot", "Box"]}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.isolation.is_none() && st.doc.node(layer).is_none());
|
||||
assert!(st.doc.symbols.is_empty());
|
||||
}
|
||||
|
||||
/// Editing a dynamic symbol keeps each instance's appearance changes to the objects its art still
|
||||
/// has, and drops the changes to an object the edit deleted.
|
||||
#[test]
|
||||
fn editing_a_dynamic_symbol_keeps_the_instances_changes_to_the_objects_still_there() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
|
||||
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})));
|
||||
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40})));
|
||||
let inst = id(&run(&mut s, "symbol.new", json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"})));
|
||||
run(&mut s, "symbol.instance.setAppearance", json!({"id": inst.0, "path": [0], "fill": "#00ff00"}));
|
||||
run(&mut s, "symbol.instance.setAppearance", json!({"id": inst.0, "path": [1], "fill": "#ff0000"}));
|
||||
// Each change by the path it names and the color it paints.
|
||||
let changes = |s: &Session| -> Vec<(Vec<u32>, String)> {
|
||||
let n = s.doc().unwrap().doc.node(inst).unwrap().clone();
|
||||
n.extras().symbol_overrides.iter().map(|o| (o.path.clone(), o.fill.as_ref().and_then(|p| p.color()).unwrap().to_hex())).collect()
|
||||
};
|
||||
let other = id(&run(&mut s, "symbol.place", json!({"name": "Pair", "x": 200, "y": 200})));
|
||||
run(&mut s, "symbol.edit", json!({"id": other.0}));
|
||||
let art = layer_art(&s);
|
||||
assert_eq!(art.len(), 2);
|
||||
// Deleting the first object: the change to the second follows it to the first place.
|
||||
run(&mut s, "select.set", json!({"ids": [art[0].0]}));
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
assert_eq!(changes(&s), vec![(vec![0], "#ff0000".to_string())], "the change to the deleted object goes");
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
assert_eq!(changes(&s), vec![(vec![0], "#ff0000".to_string())]);
|
||||
assert!(s.doc().unwrap().doc.node(inst).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_back_into_an_earlier_edit_leaves_it_to_its_own_isolation() {
|
||||
let (mut s, first, second) = two_instances();
|
||||
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 20, "height": 20})));
|
||||
run(&mut s, "select.set", json!({"ids": [second.0, other.0]}));
|
||||
let group = id(&run(&mut s, "object.group", json!({})));
|
||||
// The first edit is entered with nothing isolated.
|
||||
run(&mut s, "symbol.edit", json!({"id": first.0}));
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
// The second edit is entered with the group isolated.
|
||||
run(&mut s, "object.isolate", json!({"id": group.0}));
|
||||
run(&mut s, "symbol.edit", json!({"id": second.0}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(group));
|
||||
// Back in the first edit, leaving it goes back to what it was entered from.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
|
||||
run(&mut s, "symbol.exitEditing", json!({}));
|
||||
assert_eq!(s.doc().unwrap().isolation, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_entry_with_a_group_isolated_inside_the_art_goes_back_to_before() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
|
||||
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})));
|
||||
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 40, "y": 10, "width": 20, "height": 20})));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
let inner = id(&run(&mut s, "object.group", json!({})));
|
||||
let c = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 40, "width": 20, "height": 20})));
|
||||
run(&mut s, "select.set", json!({"ids": [inner.0, c.0]}));
|
||||
let instance = id(&run(&mut s, "symbol.new", json!({"name": "Pair"})));
|
||||
let before = s.doc().unwrap().active_layer;
|
||||
assert!(before.is_some());
|
||||
run(&mut s, "symbol.edit", json!({"id": instance.0}));
|
||||
let layer = s.doc().unwrap().doc.symbol_edit.as_ref().unwrap().layer;
|
||||
let group = {
|
||||
let st = s.doc().unwrap();
|
||||
layer_art(&s).into_iter().find(|i| st.doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))).expect("a group")
|
||||
};
|
||||
run(&mut s, "object.isolate", json!({"id": group.0}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(group));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.symbol_edit.is_none() && st.doc.node(layer).is_none());
|
||||
assert_eq!(st.isolation, None);
|
||||
assert_eq!(st.active_layer, before);
|
||||
}
|
||||
Reference in New Issue
Block a user