Files
vectorcraft/crates/engine/src/cmd/edit.rs
T
bflatasticandClaude Opus 5.5 3b99df3b9a M14.5: Preferences > Units drive every length the UI shows and reads
The General unit is the active document's units: Preferences > Units >
General sets them (one undo step) and is the units new documents start
in (and the unit shown with no document open); Document Setup changes
only the document. The Transform panel, Properties, the Control bar, the
Info panel, rulers, the status bar, Align spacing, Tabs, paragraph
indents, Pattern Options, dialog distances (Move, shape sizes, path
dialogs, Artboard Options, effect distances, Transform Each, parameter
dialogs), New Document and the canvas measurement labels (smart-guide
sizes, moves, the Measure tool, the Live Corners radius) all follow it,
live. Units > Type drives font size, leading and baseline shift; Units >
Stroke also reaches the Appearance panel and the Magic Wand weight
tolerance. Typed units ("5 mm", "12 pt") work in any field; all ten
units are choices; length preferences show in their unit and accept a
unit; offset/simplify/grid/type-size params read typed units. Typed text
starting with a multi-byte character no longer panics the parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:43:41 +07:00

295 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\"… (default: prefs unitsGeneral), title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as 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\"|\"Feet & Inches\"|\"Meters\"|\"Yards\"|\"Feet\"} the document's units: every length the UI shows and reads (General)",
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 mode = match str_param(p, "colorMode") {
Some(m) if m.eq_ignore_ascii_case("cmyk") => vectorcraft_doc::ColorMode::Cmyk,
_ => vectorcraft_doc::ColorMode::Rgb,
};
let mut d = Document::new_with_mode(w, h, mode);
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
d.units = match str_param(p, "units") {
Some(u) => Unit::named(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?,
None => s.default_units(),
};
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 }))
}
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(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
s.set_document_units(u)?;
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()?;
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
}
/// The top-level objects among `ids` (in paint order): compound-path members stand for their
/// compound, and ids whose ancestor is also listed, and layers, are dropped.
pub(crate) fn roots_of(doc: &Document, ids: Vec<NodeId>) -> Vec<NodeId> {
// A compound-path member acts as its compound (compounds are one object).
let mut sel: Vec<NodeId> = ids
.into_iter()
.map(|id| match doc.parent_of(id).and_then(|p| doc.node(p).map(|n| (p, n))) {
Some((p, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => p,
_ => id,
})
.collect();
sel.dedup();
sel.iter()
.copied()
.filter(|id| {
let anc = doc.ancestry(*id).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a))
})
.filter(|id| 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()
}