mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
986 lines
42 KiB
Rust
986 lines
42 KiB
Rust
//! Object creation and the Object menu: frames, lines, transforms, arrange, group, lock/hide,
|
|
//! fill/stroke, content type, Text Frame Options, corners, fitting, text wrap.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use designcraft_doc::{
|
|
Content, Document, Fill, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef, Story, StoryId, Stroke, TextFrame, TextFrameOptions, TextSel,
|
|
};
|
|
use designcraft_geom::{Affine, Point, Rect, Vec2, shapes};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_doc, has_selection, ok, point_param, rect_param, spread_param, str_param, targets};
|
|
use crate::{Result, Session};
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"frame.create",
|
|
"Create Frame",
|
|
[],
|
|
None,
|
|
"{spread?, rect: [x0,y0,x1,y1] (spread coords), shape?: rectangle|ellipse|polygon, content?: graphic|text|unassigned, sides?: 6, text?: string, caret?: bool}",
|
|
has_doc,
|
|
frame_create
|
|
),
|
|
cmd!("line.create", "Create Line", [], None, "{spread?, a: [x,y], b: [x,y]}", has_doc, line_create),
|
|
cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move),
|
|
cmd!("transform.resize", "Resize", [], None, "{from: rect, to: rect, content?: bool (scale content), ids?}", has_selection, transform_resize),
|
|
cmd!("transform.rotate", "Rotate", ["Object", "Transform"], None, "{angle (degrees, CCW), ids?}", has_selection, transform_rotate),
|
|
cmd!("transform.scale", "Scale", ["Object", "Transform"], None, "{sx, sy, ids?} (about the selection centre)", has_selection, |s, p| {
|
|
let sx = f64_or(p, "sx", 1.0);
|
|
let sy = f64_or(p, "sy", sx);
|
|
if sx.abs() < 1e-4 || sy.abs() < 1e-4 {
|
|
return Err(bad("transform.scale", "scale too small"));
|
|
}
|
|
apply_about_center(s, p, Affine::scale_non_uniform(sx, sy))
|
|
}),
|
|
cmd!("transform.shear", "Shear", ["Object", "Transform"], None, "{angle (degrees), ids?}", has_selection, |s, p| {
|
|
let a = f64_or(p, "angle", 0.0).clamp(-85.0, 85.0).to_radians().tan();
|
|
apply_about_center(s, p, Affine::new([1.0, 0.0, a, 1.0, 0.0, 0.0]))
|
|
}),
|
|
cmd!(
|
|
"object.matchAttributes",
|
|
"Apply Attributes (Eyedropper)",
|
|
[],
|
|
None,
|
|
"{from: id, ids?} — copy fill, stroke, corners, opacity, effects, wrap",
|
|
has_selection,
|
|
|s, p| {
|
|
let from = super::id_param(p, "from").ok_or_else(|| bad("object.matchAttributes", "missing from"))?;
|
|
let src = s.doc()?.doc.item(from).cloned().ok_or(designcraft_doc::DocError::NoItem(from))?;
|
|
set_flag(
|
|
s,
|
|
p,
|
|
move |i| {
|
|
if i.id == src.id {
|
|
return;
|
|
}
|
|
i.fill = src.fill.clone();
|
|
i.stroke = src.stroke.clone();
|
|
i.corners = src.corners;
|
|
i.opacity = src.opacity;
|
|
i.blend = src.blend;
|
|
i.effects = src.effects.clone();
|
|
},
|
|
false,
|
|
)
|
|
}
|
|
),
|
|
cmd!("transform.flip", "Flip", ["Object", "Transform"], None, "{axis: horizontal|vertical, ids?}", has_selection, |s, p| {
|
|
let h = str_param(p, "axis") != Some("vertical");
|
|
apply_about_center(s, p, if h { Affine::scale_non_uniform(-1.0, 1.0) } else { Affine::scale_non_uniform(1.0, -1.0) })
|
|
}),
|
|
cmd!(
|
|
"transform.set",
|
|
"Transform Panel",
|
|
[],
|
|
None,
|
|
"{x?, y?, width?, height?, ref?: 0..8} — reference-point based geometry",
|
|
has_selection,
|
|
transform_set
|
|
),
|
|
cmd!("object.arrange", "Arrange", ["Object", "Arrange"], None, "{to: front|forward|backward|back}", has_selection, arrange),
|
|
cmd!("object.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| s
|
|
.execute("object.arrange", &json!({"to": "front"}))),
|
|
cmd!("object.bringForward", "Bring Forward", ["Object", "Arrange"], Some("Cmd+]"), "{}", has_selection, |s, _| s
|
|
.execute("object.arrange", &json!({"to": "forward"}))),
|
|
cmd!("object.sendBackward", "Send Backward", ["Object", "Arrange"], Some("Cmd+["), "{}", has_selection, |s, _| s
|
|
.execute("object.arrange", &json!({"to": "backward"}))),
|
|
cmd!("object.sendToBack", "Send to Back", ["Object", "Arrange"], Some("Cmd+Shift+["), "{}", has_selection, |s, _| s
|
|
.execute("object.arrange", &json!({"to": "back"}))),
|
|
cmd!("object.group", "Group", ["Object"], Some("Cmd+G"), "{ids?}", has_selection, group),
|
|
cmd!("object.ungroup", "Ungroup", ["Object"], Some("Cmd+Shift+G"), "{ids?}", has_selection, ungroup),
|
|
cmd!("object.lock", "Lock", ["Object"], Some("Cmd+L"), "{ids?}", has_selection, |s, p| set_flag(s, p, |i| i.locked = true, true)),
|
|
cmd!("object.unlockAll", "Unlock All on Spread", ["Object"], Some("Cmd+Alt+L"), "{}", has_doc, |s, _| all_flag(s, |i| i.locked = false)),
|
|
cmd!("object.hide", "Hide", ["Object"], Some("Cmd+3"), "{ids?}", has_selection, |s, p| set_flag(s, p, |i| i.hidden = true, true)),
|
|
cmd!("object.showAll", "Show All on Spread", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, |s, _| all_flag(s, |i| i.hidden = false)),
|
|
cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| {
|
|
let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string();
|
|
let tint = f64_or(p, "tint", 1.0) as f32;
|
|
set_flag(s, p, move |i| i.fill = Fill { swatch: sw.clone(), tint, ..i.fill.clone() }, false)
|
|
}),
|
|
cmd!(
|
|
"object.stroke",
|
|
"Stroke",
|
|
[],
|
|
None,
|
|
"{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?, join?, ids?}",
|
|
has_selection,
|
|
|s, p| {
|
|
let p2 = p.clone();
|
|
set_flag(
|
|
s,
|
|
p,
|
|
move |i| {
|
|
let mut st = if i.stroke.is_none() && p2.get("swatch").is_none() { Stroke::default() } else { i.stroke.clone() };
|
|
if let Some(v) = p2.get("swatch").and_then(Value::as_str) {
|
|
st.swatch = v.into();
|
|
}
|
|
if let Some(v) = p2.get("tint").and_then(Value::as_f64) {
|
|
st.tint = v as f32;
|
|
}
|
|
if let Some(v) = p2.get("weight").and_then(Value::as_f64) {
|
|
st.weight = v.max(0.0);
|
|
}
|
|
for (k, apply) in [("align", 0), ("type", 1), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] {
|
|
if let Some(v) = p2.get(k).cloned() {
|
|
let _ = match apply {
|
|
0 => serde_json::from_value(v).map(|x| st.align = x),
|
|
1 => serde_json::from_value(v).map(|x| st.kind = x),
|
|
2 => serde_json::from_value(v).map(|x| st.cap = x),
|
|
3 => serde_json::from_value(v).map(|x| st.join = x),
|
|
4 => serde_json::from_value(v).map(|x| st.start = x),
|
|
_ => serde_json::from_value(v).map(|x| st.end = x),
|
|
};
|
|
}
|
|
}
|
|
i.stroke = st;
|
|
},
|
|
false,
|
|
)
|
|
}
|
|
),
|
|
cmd!("object.opacity", "Opacity", [], None, "{opacity: 0..1, blend?: normal|multiply|…, ids?}", has_selection, |s, p| {
|
|
let o = f64_or(p, "opacity", 1.0).clamp(0.0, 1.0) as f32;
|
|
let blend = p.get("blend").and_then(|b| serde_json::from_value(b.clone()).ok());
|
|
set_flag(
|
|
s,
|
|
p,
|
|
move |i| {
|
|
i.opacity = o;
|
|
if let Some(b) = blend {
|
|
i.blend = b;
|
|
}
|
|
},
|
|
false,
|
|
)
|
|
}),
|
|
cmd!(
|
|
"object.dropShadow",
|
|
"Drop Shadow",
|
|
["Object", "Effects"],
|
|
Some("Cmd+Alt+M"),
|
|
"{on?: bool, distance?, angle?, opacity?, size?, ids?}",
|
|
has_selection,
|
|
|s, p| {
|
|
let p2 = p.clone();
|
|
set_flag(
|
|
s,
|
|
p,
|
|
move |i| {
|
|
let ds = &mut i.effects.drop_shadow;
|
|
ds.on = p2.get("on").and_then(Value::as_bool).unwrap_or(!ds.on);
|
|
ds.distance = f64_or(&p2, "distance", ds.distance);
|
|
ds.angle = f64_or(&p2, "angle", ds.angle);
|
|
ds.opacity = f64_or(&p2, "opacity", ds.opacity as f64) as f32;
|
|
ds.size = f64_or(&p2, "size", ds.size);
|
|
},
|
|
false,
|
|
)
|
|
}
|
|
),
|
|
cmd!("object.content", "Content", ["Object", "Content"], None, "{type: graphic|text|unassigned, ids?}", has_selection, content_type),
|
|
cmd!(
|
|
"object.textFrameOptions",
|
|
"Text Frame Options…",
|
|
["Object"],
|
|
Some("Cmd+B"),
|
|
"{columns?, gutter?, inset?: number|[t,l,b,r], verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, ids?}",
|
|
has_selection,
|
|
text_frame_options
|
|
),
|
|
cmd!(
|
|
"object.cornerOptions",
|
|
"Corner Options…",
|
|
["Object"],
|
|
None,
|
|
"{shape: none|rounded|inverseRounded|inset|bevel|fancy, size, ids?}",
|
|
has_selection,
|
|
|s, p| {
|
|
let shape =
|
|
p.get("shape").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(designcraft_geom::corners::CornerShape::Rounded);
|
|
let size = f64_or(p, "size", 12.0);
|
|
set_flag(s, p, move |i| i.corners = designcraft_geom::corners::CornerOptions::uniform(shape, size), false)
|
|
}
|
|
),
|
|
cmd!(
|
|
"object.textWrap",
|
|
"Text Wrap",
|
|
["Window"],
|
|
Some("Cmd+Alt+W"),
|
|
"{mode: none|boundingBox|contour|jumpObject|jumpToNextColumn, offset?: number|[t,l,b,r], ids?}",
|
|
has_selection,
|
|
|s, p| {
|
|
let mode = p.get("mode").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(designcraft_doc::WrapMode::BoundingBox);
|
|
let off = match p.get("offset") {
|
|
Some(Value::Number(n)) => Some([n.as_f64().unwrap_or(0.0); 4]),
|
|
Some(v) => serde_json::from_value(v.clone()).ok(),
|
|
None => None,
|
|
};
|
|
set_flag(
|
|
s,
|
|
p,
|
|
move |i| {
|
|
i.wrap.mode = mode;
|
|
if let Some(o) = off {
|
|
i.wrap.offsets = o;
|
|
}
|
|
},
|
|
false,
|
|
)
|
|
}
|
|
),
|
|
cmd!(
|
|
"object.fit",
|
|
"Fitting",
|
|
["Object", "Fitting"],
|
|
None,
|
|
"{mode: fillProportionally|fitProportionally|fitContentToFrame|centerContent|fitFrameToContent, ids?}",
|
|
has_selection,
|
|
fit
|
|
),
|
|
cmd!("object.rename", "Rename", [], None, "{id, name}", has_doc, |s, p| {
|
|
let name = str_param(p, "name").unwrap_or("").to_string();
|
|
set_flag(s, p, move |i| i.name = name.clone(), false)
|
|
}),
|
|
cmd!("path.create", "Create Path", [], None, "{spread?, anchors: [{p:[x,y], in?:[x,y], out?:[x,y]}], closed?: bool}", has_doc, path_create),
|
|
cmd!("path.appendAnchor", "Add Anchor", [], None, "{id, anchor: {p, in?, out?}} (spread coords)", has_doc, path_append),
|
|
cmd!("path.close", "Close Path", ["Object", "Paths"], None, "{id}", has_doc, path_close),
|
|
cmd!("path.moveAnchors", "Move Anchors", [], None, "{id, anchors: [[subpath, index]], dx, dy, handle?: in|out}", has_doc, path_move_anchors),
|
|
cmd!(
|
|
"object.align",
|
|
"Align",
|
|
["Window", "Object & Layout", "Align"],
|
|
None,
|
|
"{edge: left|hcenter|right|top|vcenter|bottom, to?: selection|keyObject|margins|page|spread, ids?}",
|
|
has_selection,
|
|
align
|
|
),
|
|
cmd!(
|
|
"object.distribute",
|
|
"Distribute",
|
|
["Window", "Object & Layout", "Align"],
|
|
None,
|
|
"{axis: horizontal|vertical, by?: centers|spacing, spacing?: points, ids?}",
|
|
has_selection,
|
|
distribute
|
|
),
|
|
cmd!("object.setLayer", "Move to Layer", [], None, "{layer, ids?}", has_selection, |s, p| {
|
|
let l = designcraft_doc::LayerId(p.get("layer").and_then(Value::as_u64).unwrap_or(0));
|
|
set_flag(s, p, move |i| i.layer = l, false)
|
|
}),
|
|
]
|
|
}
|
|
|
|
fn frame_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let rect = rect_param(p, "rect").ok_or_else(|| bad("frame.create", "missing rect"))?;
|
|
let shape = str_param(p, "shape").unwrap_or("rectangle");
|
|
let content = str_param(p, "content").unwrap_or("graphic");
|
|
let sr = spread_param(p, "spread");
|
|
let lid = s.doc()?.active_layer;
|
|
let text = str_param(p, "text").unwrap_or("").to_string();
|
|
let caret = bool_or(p, "caret", content == "text");
|
|
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 100) as u32;
|
|
let rect = Rect::new(rect.x0, rect.y0, rect.x1.max(rect.x0 + 0.5), rect.y1.max(rect.y0 + 0.5));
|
|
s.edit(|d, sel| {
|
|
if d.spread(sr).is_none() {
|
|
return Err(bad("frame.create", "no such spread"));
|
|
}
|
|
let (path, sh) = match shape {
|
|
"ellipse" | "oval" => (shapes::ellipse(rect), Shape::Oval),
|
|
"polygon" => (polygon_in(rect, sides), Shape::Polygon),
|
|
_ => (shapes::rectangle(rect), Shape::Rectangle),
|
|
};
|
|
if content == "text" {
|
|
let (id, sid) = d.add_text_frame(sr, rect, lid, &text, ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() })?;
|
|
if let Some(it) = d.item_mut(id) {
|
|
it.path = path;
|
|
it.shape = sh;
|
|
}
|
|
*sel = if caret {
|
|
Selection::text(TextSel { story: sid, anchor: text.len(), focus: text.len(), frame: Some(id) })
|
|
} else {
|
|
Selection::items(vec![id])
|
|
};
|
|
return Ok(json!({"id": id.0, "story": sid.0}));
|
|
}
|
|
let id = ItemId(d.alloc());
|
|
let mut it = Item::new(id, lid, sh, path);
|
|
if content == "graphic" {
|
|
it.object_style = d.styles.default_graphic_frame.clone();
|
|
} else {
|
|
// Shapes drawn with Rectangle/Ellipse/Polygon get the default 1 pt black stroke.
|
|
it.stroke = Stroke::default();
|
|
}
|
|
d.insert_item(sr, it, None)?;
|
|
*sel = Selection::items(vec![id]);
|
|
Ok(json!({"id": id.0}))
|
|
})
|
|
}
|
|
|
|
fn polygon_in(r: Rect, sides: u32) -> designcraft_geom::PathData {
|
|
let c = r.center();
|
|
let mut p = shapes::polygon(Point::ZERO, 1.0, sides, 0.0);
|
|
// Fit the unit polygon's bounds to the rect.
|
|
if let Some(b) = p.bounds() {
|
|
let a = Affine::translate(c.to_vec2())
|
|
* Affine::scale_non_uniform(r.width() / b.width().max(1e-9), r.height() / b.height().max(1e-9))
|
|
* Affine::translate(-b.center().to_vec2());
|
|
p.transform(a);
|
|
}
|
|
p
|
|
}
|
|
|
|
fn line_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let a = point_param(p, "a").ok_or_else(|| bad("line.create", "missing a"))?;
|
|
let b = point_param(p, "b").ok_or_else(|| bad("line.create", "missing b"))?;
|
|
let sr = spread_param(p, "spread");
|
|
let lid = s.doc()?.active_layer;
|
|
s.edit(|d, sel| {
|
|
let id = ItemId(d.alloc());
|
|
let mut it = Item::new(id, lid, Shape::GraphicLine, shapes::line(a, b));
|
|
it.stroke = Stroke::default();
|
|
d.insert_item(sr, it, None)?;
|
|
*sel = Selection::items(vec![id]);
|
|
Ok(json!({"id": id.0}))
|
|
})
|
|
}
|
|
|
|
/// Deep-copy items `ids` from `src` into `dst` spread `to` (fresh ids; text frames get story copies).
|
|
pub fn duplicate_from(dst: &mut Document, src: &Document, ids: &[ItemId], to: SpreadRef, off: Vec2) -> Result<Vec<ItemId>> {
|
|
let mut out = Vec::new();
|
|
let mut story_map: HashMap<StoryId, StoryId> = HashMap::new();
|
|
for id in ids {
|
|
let Some(it) = src.item(*id) else { continue };
|
|
let mut copy = it.clone();
|
|
copy.xf = Affine::translate(off) * copy.xf;
|
|
renumber(dst, src, &mut copy, &mut story_map);
|
|
if dst.layer(copy.layer).is_none() {
|
|
copy.layer = dst.default_layer();
|
|
}
|
|
let nid = copy.id;
|
|
dst.insert_item(to, copy, None)?;
|
|
out.push(nid);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap<StoryId, StoryId>) {
|
|
it.id = ItemId(dst.alloc());
|
|
let my_id = it.id;
|
|
match &mut it.content {
|
|
Content::Text(tf) => {
|
|
let new_sid = match stories.get(&tf.story) {
|
|
Some(n) => *n,
|
|
None => {
|
|
let n = StoryId(dst.alloc());
|
|
let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n));
|
|
st.id = n;
|
|
st.frames.clear();
|
|
dst.stories.insert(n, Arc::new(st));
|
|
stories.insert(tf.story, n);
|
|
n
|
|
}
|
|
};
|
|
tf.story = new_sid;
|
|
if let Some(st) = dst.story_mut(new_sid) {
|
|
st.frames.push(my_id);
|
|
}
|
|
}
|
|
Content::Graphic(g) => {
|
|
if let Some(a) = src.assets.get(&g.asset)
|
|
&& !dst.assets.contains_key(&g.asset)
|
|
{
|
|
dst.assets.insert(g.asset, a.clone());
|
|
}
|
|
}
|
|
Content::Group { items } => {
|
|
for c in items.iter_mut() {
|
|
renumber(dst, src, Arc::make_mut(c), stories);
|
|
}
|
|
}
|
|
Content::Unassigned => {}
|
|
}
|
|
}
|
|
|
|
fn transform_move(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
|
let copy = bool_or(p, "copy", false);
|
|
let to: Option<SpreadRef> = p.get("toSpread").and_then(|v| serde_json::from_value(v.clone()).ok());
|
|
s.edit(|d, sel| {
|
|
let ids = if copy {
|
|
let src = d.clone();
|
|
let mut new = Vec::new();
|
|
for id in &ids {
|
|
let sr = d.find(*id).map(|l| l.spread).unwrap_or(SpreadRef::Doc(0));
|
|
new.extend(duplicate_from(d, &src, &[*id], to.unwrap_or(sr), Vec2::ZERO)?);
|
|
}
|
|
*sel = Selection::items(new.clone());
|
|
new
|
|
} else {
|
|
ids
|
|
};
|
|
for id in &ids {
|
|
if let Some(target) = to
|
|
&& !copy
|
|
&& let Some(loc) = d.find(*id)
|
|
&& loc.spread != target
|
|
&& loc.path.len() == 1
|
|
{
|
|
let it = d.remove_item_keep_story(*id)?;
|
|
d.insert_item(target, it, None)?;
|
|
}
|
|
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
|
if it.locked {
|
|
continue;
|
|
}
|
|
it.xf = Affine::translate((dx, dy)) * it.xf;
|
|
}
|
|
Ok(json!({"moved": ids.len()}))
|
|
})
|
|
}
|
|
|
|
fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
let from = rect_param(p, "from").ok_or_else(|| bad("transform.resize", "missing from"))?;
|
|
let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?;
|
|
let scale_content = bool_or(p, "content", false);
|
|
if from.width().abs() < 1e-9 || from.height().abs() < 1e-9 {
|
|
return Err(bad("transform.resize", "degenerate source rect"));
|
|
}
|
|
let sx = to.width() / from.width();
|
|
let sy = to.height() / from.height();
|
|
let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx };
|
|
let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy };
|
|
let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0));
|
|
s.edit(|d, _| {
|
|
for id in &ids {
|
|
let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?;
|
|
if it.locked {
|
|
continue;
|
|
}
|
|
if scale_content || matches!(it.content, Content::Group { .. }) {
|
|
it.xf = m * it.xf;
|
|
} else {
|
|
// Resize the frame's path (in inner space); content keeps its size (InDesign's default).
|
|
let inner = it.xf.inverse() * m * it.xf;
|
|
it.path.transform(inner);
|
|
}
|
|
}
|
|
Ok(json!({"resized": ids.len()}))
|
|
})
|
|
}
|
|
|
|
fn apply_about_center(s: &mut Session, p: &Value, a: Affine) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
s.edit(|d, _| {
|
|
let mut b: Option<Rect> = None;
|
|
for id in &ids {
|
|
if let Some(it) = d.item(*id) {
|
|
b = Some(b.map_or(it.bounds(), |r| r.union(it.bounds())));
|
|
}
|
|
}
|
|
let c = b.unwrap_or(Rect::ZERO).center();
|
|
let m = Affine::translate(c.to_vec2()) * a * Affine::translate(-c.to_vec2());
|
|
for id in &ids {
|
|
if let Some(it) = d.item_mut(*id) {
|
|
it.xf = m * it.xf;
|
|
}
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn transform_rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let a = f64_or(p, "angle", 0.0);
|
|
apply_about_center(s, p, Affine::rotate(-a.to_radians()))
|
|
}
|
|
|
|
fn transform_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
let rf = p.get("ref").and_then(Value::as_u64).unwrap_or(0) as usize;
|
|
s.edit(|d, _| {
|
|
let mut b: Option<Rect> = None;
|
|
for id in &ids {
|
|
if let Some(it) = d.item(*id) {
|
|
b = Some(b.map_or(it.bounds(), |r| r.union(it.bounds())));
|
|
}
|
|
}
|
|
let Some(from) = b else { return ok() };
|
|
let w = f64_or(p, "width", from.width()).max(0.01);
|
|
let h = f64_or(p, "height", from.height()).max(0.01);
|
|
let anchor = designcraft_geom::reference_point(from, rf);
|
|
// New rect keeping the reference point fixed, then moved so the ref point lands on x/y.
|
|
let fx = [0.0, 0.5, 1.0][rf % 3];
|
|
let fy = [0.0, 0.5, 1.0][(rf / 3).min(2)];
|
|
let mut to = Rect::new(anchor.x - fx * w, anchor.y - fy * h, anchor.x - fx * w + w, anchor.y - fy * h + h);
|
|
// x/y are the reference point position relative to the page origin of the first page in the spread.
|
|
let page_x = ids
|
|
.first()
|
|
.and_then(|i| d.find(*i))
|
|
.and_then(|l| {
|
|
d.spread(l.spread).map(|sp| {
|
|
let pi = sp.page_at_x(from.center().x).unwrap_or(0);
|
|
sp.pages[pi].x
|
|
})
|
|
})
|
|
.unwrap_or(0.0);
|
|
if let Some(x) = p.get("x").and_then(Value::as_f64) {
|
|
to = to + Vec2::new(x + page_x - anchor.x, 0.0);
|
|
}
|
|
if let Some(y) = p.get("y").and_then(Value::as_f64) {
|
|
to = to + Vec2::new(0.0, y - anchor.y);
|
|
}
|
|
let m = Affine::translate((to.x0, to.y0))
|
|
* Affine::scale_non_uniform(to.width() / from.width().max(1e-9), to.height() / from.height().max(1e-9))
|
|
* Affine::translate((-from.x0, -from.y0));
|
|
for id in &ids {
|
|
if let Some(it) = d.item_mut(*id) {
|
|
if matches!(it.content, Content::Group { .. }) {
|
|
it.xf = m * it.xf;
|
|
} else {
|
|
let inner = it.xf.inverse() * m * it.xf;
|
|
it.path.transform(inner);
|
|
}
|
|
}
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
let to = str_param(p, "to").unwrap_or("front").to_string();
|
|
s.edit(|d, _| {
|
|
for id in &ids {
|
|
let Some(loc) = d.find(*id) else { continue };
|
|
if loc.path.len() != 1 {
|
|
continue;
|
|
}
|
|
let sp = d.spread_mut(loc.spread).expect("found");
|
|
let i = loc.path[0];
|
|
let n = sp.items.len();
|
|
let it = sp.items.remove(i);
|
|
let j = match to.as_str() {
|
|
"front" => n - 1,
|
|
"back" => 0,
|
|
"forward" => (i + 1).min(n - 1),
|
|
_ => i.saturating_sub(1),
|
|
};
|
|
sp.items.insert(j, it);
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
if ids.len() < 2 {
|
|
return Err(bad("object.group", "select at least two objects"));
|
|
}
|
|
s.edit(|d, sel| {
|
|
let sr = d.find(ids[0]).map(|l| l.spread).ok_or(designcraft_doc::DocError::NoItem(ids[0]))?;
|
|
// Keep stacking order: collect in z-order.
|
|
let order: Vec<ItemId> = d.spread(sr).map(|sp| sp.items.iter().map(|i| i.id).filter(|i| ids.contains(i)).collect()).unwrap_or_default();
|
|
let front = d.spread(sr).and_then(|sp| sp.items.iter().position(|i| Some(&i.id) == order.last())).unwrap_or(0);
|
|
let layer = d.item(order[0]).map(|i| i.layer).unwrap_or_default();
|
|
let mut kids = Vec::new();
|
|
for id in &order {
|
|
kids.push(d.remove_item_keep_story(*id)?);
|
|
}
|
|
let gid = ItemId(d.alloc());
|
|
let mut g = Item::new(gid, layer, Shape::Group, designcraft_geom::PathData::default());
|
|
g.content = Content::Group { items: kids.into_iter().map(Arc::new).collect() };
|
|
let at = front + 1 - order.len();
|
|
d.insert_item(sr, g, Some(at))?;
|
|
*sel = Selection::items(vec![gid]);
|
|
Ok(json!({"id": gid.0}))
|
|
})
|
|
}
|
|
|
|
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
s.edit(|d, sel| {
|
|
let mut out = Vec::new();
|
|
for id in &ids {
|
|
let Some(loc) = d.find(*id) else { continue };
|
|
let Some(g) = d.item(*id).cloned() else { continue };
|
|
let Content::Group { items } = &g.content else { continue };
|
|
if loc.path.len() != 1 {
|
|
continue;
|
|
}
|
|
let sp = d.spread_mut(loc.spread).expect("found");
|
|
sp.items.remove(loc.path[0]);
|
|
for (k, c) in items.iter().enumerate() {
|
|
let mut c = (**c).clone();
|
|
c.xf = g.xf * c.xf;
|
|
out.push(c.id);
|
|
sp.items.insert(loc.path[0] + k, Arc::new(c));
|
|
}
|
|
}
|
|
*sel = Selection::items(out);
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn set_flag(s: &mut Session, p: &Value, f: impl Fn(&mut Item), deselect: bool) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
s.edit(|d, sel| {
|
|
for id in &ids {
|
|
if let Some(it) = d.item_mut(*id) {
|
|
f(it);
|
|
}
|
|
}
|
|
if deselect {
|
|
*sel = Selection::default();
|
|
}
|
|
Ok(json!({"changed": ids.len()}))
|
|
})
|
|
}
|
|
|
|
fn all_flag(s: &mut Session, f: impl Fn(&mut Item)) -> Result<Value> {
|
|
s.edit(|d, _| {
|
|
for i in 0..d.spreads.len() {
|
|
let sp = Arc::make_mut(&mut d.spreads[i]);
|
|
for it in &mut sp.items {
|
|
f(Arc::make_mut(it));
|
|
}
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn content_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
let t = str_param(p, "type").unwrap_or("unassigned").to_string();
|
|
s.edit(|d, _| {
|
|
for id in &ids {
|
|
let cur = d.item(*id).map(|i| i.content.clone());
|
|
match (t.as_str(), cur) {
|
|
("text", Some(Content::Unassigned | Content::Graphic(_))) => {
|
|
let sid = StoryId(d.alloc());
|
|
let mut st = Story::new(sid);
|
|
st.frames.push(*id);
|
|
d.stories.insert(sid, Arc::new(st));
|
|
if let Some(it) = d.item_mut(*id) {
|
|
it.content = Content::Text(TextFrame { story: sid, options: TextFrameOptions::default() });
|
|
}
|
|
}
|
|
("graphic" | "unassigned", Some(Content::Text(tf))) => {
|
|
let empty = d.story(tf.story).map(|s| s.frames.len() == 1).unwrap_or(true);
|
|
if let Some(st) = d.story_mut(tf.story) {
|
|
st.frames.retain(|f| f != id);
|
|
}
|
|
if empty {
|
|
d.stories.remove(&tf.story);
|
|
}
|
|
if let Some(it) = d.item_mut(*id) {
|
|
it.content = Content::Unassigned;
|
|
}
|
|
}
|
|
("graphic" | "unassigned", Some(Content::Graphic(_))) if t == "unassigned" => {
|
|
if let Some(it) = d.item_mut(*id) {
|
|
it.content = Content::Unassigned;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn text_frame_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let st = s.doc()?;
|
|
let mut ids = targets(s, p)?;
|
|
if ids.is_empty()
|
|
&& let Some(t) = st.selection.text
|
|
{
|
|
ids = st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default();
|
|
}
|
|
let p = p.clone();
|
|
s.edit(|d, _| {
|
|
for id in &ids {
|
|
let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue };
|
|
let o = &mut tf.options;
|
|
if let Some(v) = p.get("columns").and_then(Value::as_u64) {
|
|
o.columns = (v as u32).clamp(1, 40);
|
|
}
|
|
if let Some(v) = p.get("gutter").and_then(Value::as_f64) {
|
|
o.gutter = v.max(0.0);
|
|
}
|
|
match p.get("inset") {
|
|
Some(Value::Number(n)) => o.inset = [n.as_f64().unwrap_or(0.0); 4],
|
|
Some(v @ Value::Array(_)) => {
|
|
if let Ok(a) = serde_json::from_value(v.clone()) {
|
|
o.inset = a;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
for (k, which) in [("verticalJustification", 0), ("firstBaseline", 1), ("autoSize", 2), ("columnsKind", 3)] {
|
|
if let Some(v) = p.get(k).cloned() {
|
|
let _ = match which {
|
|
0 => serde_json::from_value(v).map(|x| o.vertical_justification = x),
|
|
1 => serde_json::from_value(v).map(|x| o.first_baseline = x),
|
|
2 => serde_json::from_value(v).map(|x| o.auto_size = x),
|
|
_ => serde_json::from_value(v).map(|x| o.columns_kind = x),
|
|
};
|
|
}
|
|
}
|
|
if let Some(v) = p.get("ignoreWrap").and_then(Value::as_bool) {
|
|
o.ignore_wrap = v;
|
|
}
|
|
if let Some(v) = p.get("balanceColumns").and_then(Value::as_bool) {
|
|
o.balance_columns = v;
|
|
}
|
|
if let Some(v) = p.get("columnRule").and_then(Value::as_bool) {
|
|
o.column_rule = v;
|
|
}
|
|
}
|
|
Ok(json!({"changed": ids.len()}))
|
|
})
|
|
}
|
|
|
|
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
let mode = str_param(p, "mode").unwrap_or("fitProportionally").to_string();
|
|
s.edit(|d, _| {
|
|
for id in &ids {
|
|
let Some(it) = d.item_mut(*id) else { continue };
|
|
let r = it.inner_bounds();
|
|
let Content::Graphic(g) = &mut it.content else { continue };
|
|
let (nw, nh) = g.size;
|
|
let cur = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh));
|
|
match mode.as_str() {
|
|
"fillProportionally" | "fitProportionally" => {
|
|
let k = if mode == "fillProportionally" { (r.width() / nw).max(r.height() / nh) } else { (r.width() / nw).min(r.height() / nh) };
|
|
g.xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k);
|
|
}
|
|
"fitContentToFrame" => g.xf = Affine::translate((r.x0, r.y0)) * Affine::scale_non_uniform(r.width() / nw, r.height() / nh),
|
|
"centerContent" => g.xf = Affine::translate(r.center() - cur.center()) * g.xf,
|
|
"fitFrameToContent" => it.path = shapes::rectangle(cur),
|
|
_ => return Err(bad("object.fit", format!("unknown mode `{mode}`"))),
|
|
}
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
trait RemoveKeep {
|
|
fn remove_item_keep_story(&mut self, id: ItemId) -> Result<Item>;
|
|
}
|
|
|
|
/// Detach an item without touching story threads (moving between spreads, grouping).
|
|
impl RemoveKeep for Document {
|
|
fn remove_item_keep_story(&mut self, id: ItemId) -> Result<Item> {
|
|
let loc = self.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
|
if loc.path.len() != 1 {
|
|
return Err(bad("object", "nested items can't be detached"));
|
|
}
|
|
let sp = self.spread_mut(loc.spread).expect("found");
|
|
Ok(Arc::unwrap_or_clone(sp.items.remove(loc.path[0])))
|
|
}
|
|
}
|
|
|
|
/// Bounds of each target item (spread space) and the alignment reference rect.
|
|
fn align_reference(d: &Document, ids: &[ItemId], to: &str, key: Option<ItemId>) -> Option<Rect> {
|
|
let bounds: Vec<Rect> = ids.iter().filter_map(|i| d.item(*i).map(Item::bounds)).collect();
|
|
let union = bounds.iter().copied().reduce(|a, b| a.union(b))?;
|
|
let first = ids.first().and_then(|i| d.find(*i))?;
|
|
let sp = d.spread(first.spread)?;
|
|
let page = sp.page_at_x(union.center().x).and_then(|pi| sp.pages.get(pi));
|
|
Some(match to {
|
|
"keyObject" => key.and_then(|k| d.item(k)).map(Item::bounds).unwrap_or(union),
|
|
"margins" => page.map(|p| p.margin_rect()).unwrap_or(union),
|
|
"page" => page.map(|p| p.bounds()).unwrap_or(union),
|
|
"spread" => sp.bounds(),
|
|
_ => union,
|
|
})
|
|
}
|
|
|
|
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
let edge = str_param(p, "edge").unwrap_or("left").to_string();
|
|
let to = str_param(p, "to").map(str::to_string).unwrap_or_else(|| if ids.len() > 1 { "selection".into() } else { "page".into() });
|
|
let key = s.doc()?.selection.key;
|
|
s.edit(|d, _| {
|
|
let r = align_reference(d, &ids, &to, key).ok_or_else(|| bad("object.align", "nothing to align"))?;
|
|
for id in &ids {
|
|
if Some(*id) == key && to == "keyObject" {
|
|
continue;
|
|
}
|
|
let Some(it) = d.item_mut(*id) else { continue };
|
|
if it.locked {
|
|
continue;
|
|
}
|
|
let b = it.bounds();
|
|
let (dx, dy) = match edge.as_str() {
|
|
"left" => (r.x0 - b.x0, 0.0),
|
|
"hcenter" => (r.center().x - b.center().x, 0.0),
|
|
"right" => (r.x1 - b.x1, 0.0),
|
|
"top" => (0.0, r.y0 - b.y0),
|
|
"vcenter" => (0.0, r.center().y - b.center().y),
|
|
"bottom" => (0.0, r.y1 - b.y1),
|
|
other => return Err(bad("object.align", format!("unknown edge `{other}`"))),
|
|
};
|
|
it.xf = Affine::translate((dx, dy)) * it.xf;
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let ids = targets(s, p)?;
|
|
if ids.len() < 3 && p.get("spacing").is_none() {
|
|
return Err(bad("object.distribute", "select at least three objects"));
|
|
}
|
|
let horizontal = str_param(p, "axis") != Some("vertical");
|
|
let by_spacing = str_param(p, "by") == Some("spacing") || p.get("spacing").is_some();
|
|
let fixed = p.get("spacing").and_then(Value::as_f64);
|
|
s.edit(|d, _| {
|
|
let mut items: Vec<(ItemId, Rect)> = ids.iter().filter_map(|i| d.item(*i).map(|it| (*i, it.bounds()))).collect();
|
|
let key = |r: &Rect| if horizontal { r.x0 } else { r.y0 };
|
|
items.sort_by(|a, b| key(&a.1).total_cmp(&key(&b.1)));
|
|
let n = items.len();
|
|
if n < 2 {
|
|
return ok();
|
|
}
|
|
let (first, last) = (items[0].1, items[n - 1].1);
|
|
let lo = |r: &Rect| if horizontal { r.x0 } else { r.y0 };
|
|
let hi = |r: &Rect| if horizontal { r.x1 } else { r.y1 };
|
|
let size = |r: &Rect| hi(r) - lo(r);
|
|
let mut targets: Vec<f64> = Vec::with_capacity(n);
|
|
if by_spacing {
|
|
let total: f64 = items.iter().map(|(_, r)| size(r)).sum();
|
|
let gap = fixed.unwrap_or_else(|| (hi(&last) - lo(&first) - total) / (n - 1) as f64);
|
|
let mut pos = lo(&first);
|
|
for (_, r) in &items {
|
|
targets.push(pos);
|
|
pos += size(r) + gap;
|
|
}
|
|
} else {
|
|
let c0 = (lo(&first) + hi(&first)) / 2.0;
|
|
let c1 = (lo(&last) + hi(&last)) / 2.0;
|
|
for (k, (_, r)) in items.iter().enumerate() {
|
|
targets.push(c0 + (c1 - c0) * k as f64 / (n - 1) as f64 - size(r) / 2.0);
|
|
}
|
|
}
|
|
for ((id, r), t) in items.iter().zip(targets) {
|
|
let delta = t - lo(r);
|
|
if let Some(it) = d.item_mut(*id) {
|
|
let v = if horizontal { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) };
|
|
it.xf = Affine::translate(v) * it.xf;
|
|
}
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn anchor_from(v: &Value) -> Option<designcraft_geom::Anchor> {
|
|
let pt = |k: &str| -> Option<Point> {
|
|
let a = v.get(k)?.as_array()?;
|
|
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
|
};
|
|
let p = pt("p")?;
|
|
let h_in = pt("in").unwrap_or(p);
|
|
let h_out = pt("out").unwrap_or(p);
|
|
let smooth = (h_in - p).hypot() > 1e-9 || (h_out - p).hypot() > 1e-9;
|
|
Some(designcraft_geom::Anchor {
|
|
p,
|
|
h_in,
|
|
h_out,
|
|
kind: if smooth { designcraft_geom::AnchorKind::Smooth } else { designcraft_geom::AnchorKind::Corner },
|
|
})
|
|
}
|
|
|
|
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let anchors: Vec<designcraft_geom::Anchor> =
|
|
p.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from).collect()).unwrap_or_default();
|
|
if anchors.len() < 2 {
|
|
return Err(bad("path.create", "a path needs at least two anchors"));
|
|
}
|
|
let closed = bool_or(p, "closed", false);
|
|
let sr = spread_param(p, "spread");
|
|
let lid = s.doc()?.active_layer;
|
|
s.edit(|d, sel| {
|
|
let id = ItemId(d.alloc());
|
|
let shape = if anchors.len() == 2 && !closed { Shape::GraphicLine } else { Shape::Path };
|
|
let mut it = Item::new(id, lid, shape, designcraft_geom::PathData::single(designcraft_geom::SubPath::new(anchors, closed)));
|
|
it.stroke = Stroke::default();
|
|
d.insert_item(sr, it, None)?;
|
|
*sel = Selection::items(vec![id]);
|
|
Ok(json!({"id": id.0}))
|
|
})
|
|
}
|
|
|
|
fn path_append(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = super::id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
|
|
let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?;
|
|
s.edit(|d, _| {
|
|
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
|
let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse();
|
|
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
|
let sp = it.path.subpaths.last_mut().ok_or_else(|| bad("path.appendAnchor", "empty path"))?;
|
|
sp.anchors.push(a.transform(inv));
|
|
if it.shape == Shape::GraphicLine {
|
|
it.shape = Shape::Path;
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn path_close(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
|
|
s.edit(|d, _| {
|
|
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
|
for sp in &mut it.path.subpaths {
|
|
sp.closed = true;
|
|
}
|
|
if it.shape == Shape::GraphicLine {
|
|
it.shape = Shape::Path;
|
|
}
|
|
ok()
|
|
})
|
|
}
|
|
|
|
fn path_move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let id = super::id_param(p, "id").ok_or_else(|| bad("path.moveAnchors", "missing id"))?;
|
|
let which: Vec<(usize, usize)> = p.get("anchors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
|
let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
|
let handle = str_param(p, "handle").map(str::to_string);
|
|
s.edit(|d, _| {
|
|
let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
|
let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default();
|
|
// Spread-space delta → inner-space delta (linear part only).
|
|
let inv = m.inverse();
|
|
let dv = (inv * Point::new(d0.x, d0.y)) - (inv * Point::ZERO);
|
|
let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
|
for (si, ai) in &which {
|
|
let Some(a) = it.path.anchor_mut(*si, *ai) else { continue };
|
|
match handle.as_deref() {
|
|
Some("in") => {
|
|
a.h_in += dv;
|
|
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
|
a.h_out = a.p - (a.h_in - a.p);
|
|
}
|
|
}
|
|
Some("out") => {
|
|
a.h_out += dv;
|
|
if a.kind == designcraft_geom::AnchorKind::Smooth {
|
|
a.h_in = a.p - (a.h_out - a.p);
|
|
}
|
|
}
|
|
_ => a.translate(dv),
|
|
}
|
|
}
|
|
if it.shape == Shape::Rectangle || it.shape == Shape::Oval || it.shape == Shape::Polygon {
|
|
it.shape = Shape::Path;
|
|
}
|
|
ok()
|
|
})
|
|
}
|