Files
vectorcraft/crates/engine/src/cmd/object/arrange.rs
T
dotCooCoo 5ac7d97d85 Guides: ruler guides are document objects on their layers
Ruler guides were a list kept beside the layers. Each guide is now an
object of its layer or sublayer, always directly in it and above the
layer's art. These behave as on main:

- Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All,
  Select All, the art bounds, clicks and marquees, and a layer's
  Pathfinder and Transform effects leave them out. The Layers panel
  lists them as guide rows of their layer, the highest guide.list
  index first.
- Deleting a layer deletes its guides, and merging layers keeps their
  guides in the merged layer.
- The guide commands number guides as guide.list does, in the order
  they were made.

New: each guide takes an id from the document's object ids. An object
command given a guide's id fails with an error, and node.move cannot
put a guide into a group or out of its layers. A guide made while an
opacity mask, a symbol or a pattern is edited moves to the top
visible, unlocked layer when editing ends.

File format: native files still save the guides in the document's
guides list, in every file version, so older builds read them. Each
entry may now also carry the guide's id, name, locked and hidden
flags; older builds ignore those fields. Opening a file puts each
saved guide on its layer (on the top visible, unlocked layer when the
file names none or a layer it does not have), and each saved guide
counts against the object limit for opening a file. A ruler guide node
that a file holds below a layer's art moves up to the layer's guides,
and one anywhere else (in a group or another object, beside the
layers, or in a symbol) is removed. Ruler guides read from an .ai file
become guide objects on the layer they were on, numbered in the order
the file lists them.
2026-11-11 09:52:28 -08:00

398 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::align::choice;
use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, json, object_param, ok, selected_roots};
pub(super) enum Arrange {
Front,
Forward,
Backward,
Back,
}
pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
let mut ids = selected_roots(s)?;
if matches!(how, Arrange::Forward | Arrange::Front) {
ids.reverse();
}
let label = match how {
Arrange::Front => "Bring to Front",
Arrange::Forward => "Bring Forward",
Arrange::Backward => "Send Backward",
Arrange::Back => "Send to Back",
};
// When nothing changes order, the document and its undo history stay as they are.
crate::cmd::overprint::edit_counted(s, label, |d| {
let mut moved = 0;
// Arrange within each parent independently (selections may span layers/groups).
let mut by_parent: Vec<(Option<vectorcraft_doc::NodeId>, Vec<vectorcraft_doc::NodeId>)> = vec![];
for id in &ids {
let par = d.parent_of(*id);
match by_parent.iter_mut().find(|(p, _)| *p == par) {
Some((_, v)) => v.push(*id),
None => by_parent.push((par, vec![*id])),
}
}
for (par, mut group) in by_parent {
// In a clip group or a clipping layer the bottom child is the clipping path: it stays
// there, and the art above it never goes below it (#3188).
let floor = usize::from(par.and_then(|p| d.node(p)).is_some_and(vectorcraft_doc::Node::clips));
if floor > 0 {
group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor));
}
for (k, id) in group.iter().enumerate() {
let Some((_, idx, _)) = d.position(*id) else { continue };
// The parent's art, without the ruler guides that stay above it.
let len = d.children(par).map_or(0, |ch| Node::guide_run_start(ch));
if len <= floor {
continue;
}
let last = len - 1;
let to = match how {
Arrange::Front => last.saturating_sub(k).max(floor),
Arrange::Back => (floor + k).min(last),
Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx),
Arrange::Backward => idx.saturating_sub(1).max(floor + k.min(idx.saturating_sub(floor))),
};
if to != idx {
d.move_node(*id, par, to)?;
moved += 1;
}
}
}
Ok(moved)
})?;
ok()
}
pub(super) fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// Ids are reused after undo: the remembered current layer must still be a layer.
let st = s.doc()?;
let layer = st.current_layer().or_else(|| st.doc.default_layer()).ok_or_else(|| EngineError::Other("no current layer".into()))?;
// When the current layer is hidden or locked, or is inside a hidden or locked layer, the
// command fails with the error that `DocState::target_parent` gives for new art.
if !st.doc.is_editable(layer) {
return Err(EngineError::Other("the target layer is locked or hidden".into()));
}
// When the selected objects are already the topmost in the current layer, in paint order, the
// document and its undo history stay as they are.
// (The layer's ruler guides stay above its art.)
let art = |c: &&std::sync::Arc<Node>| !c.is_ruler_guide();
if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().filter(art).map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) {
return ok();
}
s.edit("Send to Current Layer", |d, _| {
let moved = d.remove_many(&ids)?;
let ch = d.children_mut(Some(layer))?;
let at = Node::guide_run_start(ch);
ch.splice(at..at, moved);
Ok(())
})?;
ok()
}
pub(super) fn group(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let gid = s.edit("Group", |d, sel| {
let gid = group_nodes(d, &ids)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
if ids.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let gid = d.alloc_id();
Ok(d.wrap(ids, |children| Node::group(gid, children))?)
}
pub(super) fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
// With no group selected, the document, the selection and the undo history stay as they are.
let d = &s.doc()?.doc;
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }))) {
return ok();
}
s.edit("Ungroup", |d, sel| {
let mut new_sel = vec![];
let mut ungrouped = vec![];
for id in &ids {
let Some(n) = d.node(*id) else { continue };
let NodeKind::Group { children, clip } = &n.kind else {
new_sel.push(*id);
continue;
};
let mut out = Vec::with_capacity(children.len());
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
// The clipping path keeps its paint (none, unless painted after Make).
if *clip
&& k == 0
&& let NodeKind::Path { clipping, .. } = &mut c.kind
{
*clipping = false;
}
// Each member keeps its own opacity; the group's opacity goes with the group.
new_sel.push(c.id);
out.push(c);
}
ungrouped.push((*id, out));
}
d.insert_beside(ungrouped, true)?;
sel.set(new_sel);
Ok(())
})?;
ok()
}
pub(super) fn lock(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_lock_hide_roots(s)?;
// With every selected object already locked, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| n.locked)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Lock", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = true;
}
}
sel.clear();
Ok(())
})?;
ok()
}
/// The selected objects ([`selected_roots`]) that Lock › Selection and Hide › Selection change: in
/// isolation mode, only those inside the isolated object, where a path of the isolated compound
/// path is an object of its own.
fn selected_lock_hide_roots(s: &Session) -> Result<Vec<NodeId>> {
match crate::cmd::ordinary_isolation(s.doc()?) {
Some(root) => crate::cmd::edit::selected_roots_inside(s, root, false),
None => selected_roots(s),
}
}
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if !n.is_layer() && !n.is_ruler_guide() && f(n) {
v.push(n.id)
}
});
v
}
/// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole
/// document, inside the isolated object in isolation mode, or with `scope: "group"` inside the
/// groups that hold the selected objects (in isolation mode, only the objects of those groups
/// that are inside the isolated object). A selected group is the group that holds its own
/// members, and the objects of a group inside another such group are taken once. With
/// `scope: "group"` and no selected object in a group, it returns an error.
fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let d = &st.doc;
if choice(p, cmd, "scope", &["document", "group"])? != Some("group") {
if let Some(root) = crate::cmd::ordinary_isolation(st) {
let mut ids = vec![];
if let Some(node) = d.node(root) {
for child in node.children().into_iter().flatten() {
child.walk(&mut |n| {
if !n.is_layer() && !n.is_ruler_guide() && f(n) {
ids.push(n.id);
}
});
}
}
return Ok(ids);
}
return Ok(collect_flag(d, f));
}
let is_group = |id: &NodeId| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }));
let mut groups: Vec<NodeId> = st.selection.objects.iter().filter_map(|id| d.ancestry(*id)?.into_iter().rev().find(is_group)).collect();
groups.sort();
groups.dedup();
if groups.is_empty() {
return Err(EngineError::Other("nothing selected is in a group".into()));
}
let mut v = vec![];
for g in &groups {
if d.ancestry(*g).is_some_and(|a| a.iter().any(|up| up != g && groups.contains(up))) {
continue;
}
for c in d.node(*g).and_then(Node::children).into_iter().flatten() {
c.walk(&mut |n| {
if !n.is_layer() && !n.is_ruler_guide() && f(n) {
v.push(n.id)
}
});
}
}
// In isolation mode only the objects inside the isolated object are taken.
if let Some(root) = crate::cmd::ordinary_isolation(st) {
v.retain(|id| crate::cmd::inside_isolation(d, root, *id));
}
Ok(v)
}
pub(super) fn unlock_all(s: &mut Session, p: &Value) -> Result<Value> {
let ids = collect_flag_in_scope(s, "object.unlockAll", p, |n| n.locked)?;
// With nothing locked, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Unlock All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = false;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
pub(super) fn hide(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_lock_hide_roots(s)?;
// With every selected object already hidden, the document and its undo history stay as they
// are, and the objects are deselected.
let d = &s.doc()?.doc;
if ids.iter().all(|id| d.node(*id).is_none_or(|n| !n.visible)) {
s.select(|_, sel| sel.clear())?;
return ok();
}
s.edit("Hide", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = false;
}
}
sel.clear();
Ok(())
})?;
ok()
}
pub(super) fn show_all(s: &mut Session, p: &Value) -> Result<Value> {
let ids = collect_flag_in_scope(s, "object.showAll", p, |n| !n.visible)?;
// With nothing hidden, the document, the selection and the undo history stay as they are.
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
s.edit("Show All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = true;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
let id = object_param(p, "id", "object.isolate")?
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let guides_locked = s.menu.guides_locked;
let (isolation, prev) = {
let st = s.doc_mut()?;
// Groups, layers and single objects (a path, a compound path, an image…); type isn't
// isolated: a double-click on it edits its text.
if matches!(st.doc.node(id).ok_or(EngineError::NoNode(id))?.kind, NodeKind::Text(_)) {
return Err(bad("object.isolate", "type objects can't be isolated"));
}
let isolation =
st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id);
// A hidden or locked object, one in a hidden or locked group or layer, and a guide path while
// Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them.
// A Shaper Group isolates its source group, so that group is the one checked.
if !st.doc.is_editable(isolation) {
return Err(bad("object.isolate", "the object is hidden or locked"));
}
if guides_locked && crate::is_guide_path(&st.doc, isolation) {
return Err(EngineError::Other("guides are locked".into()));
}
// While a symbol is edited, only its art on the editing layer is isolated.
if let Some(layer) = st.doc.symbol_edit.as_ref().map(|e| e.layer)
&& !st.doc.ancestry(isolation).is_some_and(|a| a.contains(&layer))
{
return Err(bad("object.isolate", "the object is outside the isolated group"));
}
(isolation, st.isolation)
};
if let Some(prev) = prev
&& prev != isolation
{
crate::cmd::settle_interaction(s)?;
let st = s.doc_mut()?;
crate::cmd::distortcmds::finish_edit_text(st, prev);
}
let st = s.doc_mut()?;
st.isolation = Some(isolation);
st.selection.clear();
// New art on a locked artboard, now deselected, is protected.
st.sync_new_art();
st.revision += 1;
ok()
}
pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result<Value> {
// 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 && st.doc.ancestry(*i).is_some_and(|a| a.contains(&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
};
if let Some(leave_mode) = leave_mode {
if let Some(i) = st.isolation {
crate::cmd::distortcmds::finish_edit_text(s.doc_mut()?, i);
}
leave_mode(s, p)?;
return ok();
}
let menu = s.menu.clone();
let prev = s.active().and_then(|d| d.isolation);
if let Some(i) = prev {
crate::cmd::settle_interaction(s)?;
let st = s.doc_mut()?;
st.isolation = None;
crate::cmd::distortcmds::finish_edit_text(st, i);
let target = st.doc.parent_of(i).filter(|p| st.doc.node(*p).is_some_and(|n| n.extras().shaper.is_some())).unwrap_or(i);
st.selection.set([target]);
// A guide path isolated while Lock Guides is on isn't selected (#1534).
crate::deselect_hidden(&menu, &st.doc, &mut st.selection);
// The art on a locked artboard can't be selected.
st.protect_locked_artboards(&[], &Default::default(), None);
}
let st = s.doc_mut()?;
st.revision += 1;
ok()
}