mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 04:53:40 +00:00
While a mask is edited, the Transparency panel and its commands act on the masked object (Release leaves editing first). Mask commands and transparency.set take ids and work without a selection; Make Opacity Mask with one object adds an empty mask and enters editing. Opacity is 0..100 in every command. transparency.info reports the panel's values with null for mixed ones, shown blank. Saves and exports never include the editing layer (Document::without_edit_modes; mask_edit is not serialized). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
12 KiB
Rust
298 lines
12 KiB
Rust
//! File (document-level) and Edit commands, plus document queries.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{Document, Node, NodeId, Unit};
|
|
use vectorcraft_geom::{Affine, Vec2};
|
|
|
|
use super::*;
|
|
use crate::{EngineError, inspect};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"file.new",
|
|
"New…",
|
|
["File"],
|
|
Some("Cmd+N"),
|
|
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}",
|
|
always,
|
|
file_new
|
|
),
|
|
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
|
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
|
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
|
|
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
|
|
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
|
cmd!(
|
|
"document.setUnits",
|
|
"Units",
|
|
["File", "Document Setup"],
|
|
None,
|
|
"{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"}",
|
|
has_doc,
|
|
set_units
|
|
),
|
|
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
|
|
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
|
|
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, cut),
|
|
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, copy),
|
|
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{dx?, dy?}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset)),
|
|
cmd!("edit.pasteInFront", "Paste in Front", ["Edit"], Some("Cmd+F"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Front)),
|
|
cmd!("edit.pasteInBack", "Paste in Back", ["Edit"], Some("Cmd+B"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Back)),
|
|
cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace)),
|
|
cmd!("edit.pasteOnAllArtboards", "Paste on All Artboards", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clipboard, |s, p| paste(
|
|
s,
|
|
p,
|
|
PasteMode::AllArtboards
|
|
)),
|
|
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?}", has_selection, clear),
|
|
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
|
|
]
|
|
}
|
|
|
|
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let w = f64_or(p, "width", 612.0);
|
|
let h = f64_or(p, "height", 792.0);
|
|
if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 {
|
|
return Err(bad("file.new", "width/height must be positive and within the canvas"));
|
|
}
|
|
let mut d = Document::new(w, h);
|
|
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
|
|
if let Some(u) = str_param(p, "units") {
|
|
d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?;
|
|
}
|
|
if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) {
|
|
d.color_mode = vectorcraft_doc::ColorMode::Cmyk;
|
|
}
|
|
let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
|
|
for i in 1..n {
|
|
let r = vectorcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0);
|
|
d.artboards.push(vectorcraft_doc::Artboard {
|
|
id: (i + 1) as u32,
|
|
name: format!("Artboard {}", i + 1),
|
|
rect: r,
|
|
show_center_mark: false,
|
|
show_cross_hairs: false,
|
|
});
|
|
}
|
|
let i = s.add_document(d, None);
|
|
Ok(json!({ "index": i }))
|
|
}
|
|
|
|
pub(crate) fn parse_unit(u: &str) -> Option<Unit> {
|
|
let n = u.to_ascii_lowercase();
|
|
Unit::ALL.into_iter().find(|x| x.label().to_ascii_lowercase() == n || x.suffix() == n)
|
|
}
|
|
|
|
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
|
|
if !s.close_document(i) {
|
|
return Err(bad("file.close", "no such document"));
|
|
}
|
|
ok()
|
|
}
|
|
|
|
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing index"))? as usize;
|
|
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
|
|
}
|
|
|
|
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
|
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
|
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
|
|
}
|
|
|
|
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let u = str_param(p, "units").and_then(parse_unit).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
|
|
s.edit("Document Setup", |d, _| {
|
|
d.units = u;
|
|
Ok(())
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
|
s.cancel_interaction()?;
|
|
let st = s.doc_mut()?;
|
|
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
|
|
let label = e.label.clone();
|
|
let editing = st.doc.mask_edit.map(|m| m.layer);
|
|
st.history.redo.push(crate::HistoryEntry {
|
|
label: e.label,
|
|
doc: std::mem::replace(&mut st.doc, e.doc),
|
|
selection: std::mem::replace(&mut st.selection, e.selection),
|
|
});
|
|
st.revision += 1;
|
|
st.selection.prune(&st.doc);
|
|
super::maskedit::follow_history(st, editing);
|
|
Ok(json!({ "undone": label }))
|
|
}
|
|
|
|
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let st = s.doc_mut()?;
|
|
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
|
|
let label = e.label.clone();
|
|
let editing = st.doc.mask_edit.map(|m| m.layer);
|
|
st.history.undo.push(crate::HistoryEntry {
|
|
label: e.label,
|
|
doc: std::mem::replace(&mut st.doc, e.doc),
|
|
selection: std::mem::replace(&mut st.selection, e.selection),
|
|
});
|
|
st.revision += 1;
|
|
st.selection.prune(&st.doc);
|
|
super::maskedit::follow_history(st, editing);
|
|
Ok(json!({ "redone": label }))
|
|
}
|
|
|
|
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
|
|
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
|
let st = s.doc()?;
|
|
// A selected compound-path member acts as its compound (Illustrator treats compounds as one object).
|
|
let mut sel: Vec<NodeId> = st
|
|
.selection
|
|
.in_paint_order(&st.doc)
|
|
.into_iter()
|
|
.map(|id| match st.doc.parent_of(id).and_then(|p| st.doc.node(p).map(|n| (p, n))) {
|
|
Some((p, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => p,
|
|
_ => id,
|
|
})
|
|
.collect();
|
|
sel.dedup();
|
|
Ok(sel
|
|
.iter()
|
|
.copied()
|
|
.filter(|id| {
|
|
let anc = st.doc.ancestry(*id).unwrap_or_default();
|
|
!anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a))
|
|
})
|
|
.filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer()))
|
|
.collect())
|
|
}
|
|
|
|
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
|
let roots = selected_roots(s)?;
|
|
let st = s.doc()?;
|
|
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
|
|
let n = nodes.len();
|
|
s.clipboard = nodes;
|
|
Ok(json!({ "copied": n }))
|
|
}
|
|
|
|
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
|
copy(s, p)?;
|
|
clear(s, &json!({}))
|
|
}
|
|
|
|
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
|
// Direct-selected anchors: delete those anchors instead of whole objects.
|
|
if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
|
|
return super::path::delete_anchors(s, p);
|
|
}
|
|
let ids = match ids_param(p, "ids") {
|
|
Some(v) => v,
|
|
None => selected_roots(s)?,
|
|
};
|
|
s.edit("Clear", |d, sel| {
|
|
for id in &ids {
|
|
if d.node(*id).is_some_and(|n| n.is_layer()) {
|
|
continue;
|
|
}
|
|
let _ = d.remove(*id);
|
|
}
|
|
sel.clear();
|
|
Ok(())
|
|
})?;
|
|
ok()
|
|
}
|
|
|
|
enum PasteMode {
|
|
Offset,
|
|
Front,
|
|
Back,
|
|
InPlace,
|
|
AllArtboards,
|
|
}
|
|
|
|
fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
|
|
let clip = s.clipboard.clone();
|
|
let off = s.prefs.paste_offset;
|
|
let st = s.doc()?;
|
|
let parent = st.insertion_parent();
|
|
// Front/back: relative to the selection (top-most / bottom-most selected object).
|
|
let anchor = match mode {
|
|
PasteMode::Front | PasteMode::Back => {
|
|
let order = st.selection.in_paint_order(&st.doc);
|
|
let a = if matches!(mode, PasteMode::Front) { order.last() } else { order.first() };
|
|
a.and_then(|id| st.doc.position(*id))
|
|
}
|
|
_ => None,
|
|
};
|
|
let artboards: Vec<vectorcraft_geom::Rect> = st.doc.artboards.iter().map(|a| a.rect).collect();
|
|
let dx = f64_or(p, "dx", off);
|
|
let dy = f64_or(p, "dy", off);
|
|
let label = match mode {
|
|
PasteMode::Front => "Paste in Front",
|
|
PasteMode::Back => "Paste in Back",
|
|
PasteMode::InPlace => "Paste in Place",
|
|
PasteMode::AllArtboards => "Paste on All Artboards",
|
|
PasteMode::Offset => "Paste",
|
|
};
|
|
let ids = s.edit(label, |d, sel| {
|
|
let mut new_ids = vec![];
|
|
let placements: Vec<Affine> = match mode {
|
|
PasteMode::Offset => vec![Affine::translate((dx, dy))],
|
|
PasteMode::AllArtboards => {
|
|
let src = d.artboards.first().map(|a| a.rect.origin()).unwrap_or_default();
|
|
artboards.iter().map(|r| Affine::translate(r.origin() - src)).collect()
|
|
}
|
|
_ => vec![Affine::IDENTITY],
|
|
};
|
|
for xf in placements {
|
|
for (k, n) in clip.iter().enumerate() {
|
|
let mut c = d.reid(n);
|
|
if xf != Affine::IDENTITY {
|
|
c.transform(xf, false);
|
|
}
|
|
let (par, idx) = match anchor {
|
|
Some((par, i, _)) => (par, if matches!(mode, PasteMode::Front) { i + 1 + k } else { i + k }),
|
|
None => (parent, usize::MAX),
|
|
};
|
|
new_ids.push(d.insert(par, idx, c)?);
|
|
}
|
|
}
|
|
sel.set(new_ids.iter().copied());
|
|
Ok(new_ids)
|
|
})?;
|
|
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
|
}
|
|
|
|
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let roots = selected_roots(s)?;
|
|
let dx = f64_or(p, "dx", 0.0);
|
|
let dy = f64_or(p, "dy", 0.0);
|
|
let ids = s.edit("Duplicate", |d, sel| duplicate_in(d, sel, &roots, Affine::translate((dx, dy))))?;
|
|
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
|
}
|
|
|
|
/// Duplicate `roots` directly above each original, transform the copies, select them.
|
|
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
|
|
let mut out = vec![];
|
|
for id in roots {
|
|
let Some((par, idx, _)) = d.position(*id) else { continue };
|
|
let Some(n) = d.node(*id).cloned() else { continue };
|
|
let mut c = d.reid(&n);
|
|
c.transform(xf, true);
|
|
out.push(d.insert(par, idx + 1, c)?);
|
|
}
|
|
sel.set(out.iter().copied());
|
|
Ok(out)
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub(crate) fn arc_nodes(v: Vec<Node>) -> Vec<Arc<Node>> {
|
|
v.into_iter().map(Arc::new).collect()
|
|
}
|