Files
vectorcraft/crates/engine/src/cmd/object/arrange.rs
T
dotCooCoo fb41d79f28 Layers: ruler guide rows have their own eye and lock, and go to the trash
The eye and lock on a guide's row in the Layers panel hide or lock that
guide alone, in one undo step, and a drag down either column takes guide
rows along with the other rows it passes. A hidden guide is not drawn and
does not snap, and its row is dimmed; a locked guide is drawn and snaps
but can't be selected, moved or deleted. Both can be changed while the
guide's layer is hidden or locked, and the guide shows or can be edited
only when its layer allows it as well. The state is kept through save
and reopen.

- layer.setProps takes ruler guide node ids; every other command still
  refuses them.
- guide.list rows carry the guide's node id and its own hidden and
  locked state.
- Alt-click and Ctrl/Cmd+Alt-click on a guide row's eye or lock act on
  the other layers, as on any row in that layer.
- Object > Lock > Selection and Object > Hide > Selection lock or hide the
  selected ruler guides with the selected art, in one undo step, outside
  isolation mode. Unlock All and Show All, document scope, also unlock or
  show guides locked or hidden on their own and select them as guides.
- Guide rows dropped on the Layers panel's trash delete their guides
  (guide.remove {indexes}). A click on a guide row clears the highlighted
  rows, so Delete deletes the guide; with no row highlighted the trash
  deletes the selected guides.
- guide.remove and guide.setLayer refuse index together with indexes and
  an indexes value that is not a list, and count a repeated index once.
  guide.select, guide.remove and guide.setLayer refuse an indexes list of
  more than 10,000 entries; guide.setLayer cut such a list short before.
- Moving, deleting or moving to another layer a guide that is hidden or
  locked on its own fails with "the guide is hidden or locked"; a guide
  on a hidden or locked layer still fails with "the guide is on a hidden
  or locked layer".

Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md.
2026-11-13 06:07:27 -08:00

417 lines
17 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`]) and ruler guides that Lock › Selection and Hide ›
/// Selection change: in isolation mode, only the objects inside the isolated object, where a path
/// of the isolated compound path is an object of its own. A guide counts while it can be edited
/// (it and its layer shown and unlocked, and Lock Guides off).
fn selected_lock_hide_roots(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
match crate::cmd::ordinary_isolation(st) {
Some(root) => crate::cmd::edit::selected_roots_inside(s, root, false),
None => {
let mut ids = selected_roots(s)?;
if !s.guides_locked() {
let d = &st.doc;
ids.extend(st.selection.guides.iter().copied().filter(|id| d.ruler_guide(*id).is_some_and(|g| d.guide_editable(&g))));
}
Ok(ids)
}
}
}
/// The objects and ruler guides in `d` that `f` picks (layers never).
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if !n.is_layer() && f(n) {
v.push(n.id)
}
});
v
}
/// Select what Unlock All or Show All changed (`ids`): the objects, and the ruler guides among
/// them as guides (a guide that still can't be edited is then dropped from the selection).
fn select_changed(d: &Document, sel: &mut vectorcraft_doc::Selection, ids: &[NodeId]) {
let guides: std::collections::HashSet<NodeId> = d.ruler_guides().into_iter().filter_map(|g| g.id).collect();
let (on_guides, objects): (Vec<NodeId>, Vec<NodeId>) = ids.iter().copied().partition(|id| guides.contains(id));
sel.set(objects);
sel.guides = on_guides;
}
/// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole
/// document (its ruler guides included), 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;
}
}
select_changed(d, sel, &ids);
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;
}
}
select_changed(d, sel, &ids);
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()
}