Files
deckcraft/crates/engine/src/cmd/shape.rs
T

734 lines
28 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.
//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles,
//! names and alt text.
use serde_json::{Value, json};
use slidecraft_color::{ColorTransform, Rgba, SchemeSlot};
use slidecraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow};
use slidecraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
use super::*;
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.connect",
"Connect Shapes",
[],
None,
"{from: shape id, to: shape id, fromSite?, toSite?: site index (default: the closest pair), id?: existing line to glue (else a new connector), preset?: straightConnector1|bentConnector3|curvedConnector3} → {id}",
has_slide,
connect
),
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate),
cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip),
cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust),
cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change),
cmd!(
"shape.fill",
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}",
has_selection,
fill
),
cmd!(
"shape.line",
"Shape Outline",
["Shape Format", "Shape Styles"],
None,
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?, join?, head?: none|triangle|stealth|diamond|oval|arrow, tail?, reset?, ids?}",
has_selection,
line
),
cmd!(
"shape.effects",
"Shape Effects",
["Shape Format", "Shape Styles"],
None,
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, reset?, ids?}",
has_selection,
effects
),
cmd!(
"shape.quickStyle",
"Quick Styles",
["Home", "Drawing"],
None,
"{index: 0..41 (theme style row×accent), ids?}",
has_selection,
quick_style
),
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock),
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
cmd!(
"picture.adjust",
"Corrections",
["Picture Format", "Adjust"],
None,
"{brightness?: -1..1, contrast?: -1..1, saturation?: 0..4, grayscale?: bool, transparency?: 0..1, reset?: bool, ids?}",
has_selection,
picture_adjust
),
cmd!("picture.reset", "Reset Picture", ["Picture Format", "Adjust"], None, "{ids?}", has_selection, picture_reset),
cmd!(
"picture.change",
"Change Picture",
["Picture Format", "Adjust"],
None,
"{path? | data: base64, name?, ids?}",
has_selection,
picture_change
),
cmd!(
"media.options",
"Playback",
["Playback"],
None,
"{autoplay?, loop?, rewind?, acrossSlides?, hide?, fullScreen?, volume?: 0..1, trimStart?: ms, trimEnd?: ms, fadeIn?: ms, fadeOut?: ms, ids?}",
has_selection,
media_options
),
]
}
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
edit_shapes(s, p, "shape.move", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
}
Ok(())
})
}
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
let lock = bool_or(p, "lockAspect", false);
edit_shapes(s, p, "shape.resize", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
match (w, h, lock) {
(Some(w), _, true) => {
x.w = w;
x.h = if ar > 0.0 { w / ar } else { x.h };
}
(None, Some(h), true) => {
x.h = h;
x.w = h * ar;
}
_ => {
x.w = w.unwrap_or(x.w);
x.h = h.unwrap_or(x.h);
}
}
// Rotated shapes resize about their centre; others keep the top-left.
if x.rot != 0.0 {
x.x = cx - x.w / 2.0;
x.y = cy - x.h / 2.0;
}
}
Ok(())
})
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, p, "shape.setBounds", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
x.x = f64_param(p, "x").unwrap_or(x.x);
x.y = f64_param(p, "y").unwrap_or(x.y);
x.w = f64_param(p, "w").unwrap_or(x.w).max(0.0);
x.h = f64_param(p, "h").unwrap_or(x.h).max(0.0);
}
Ok(())
})
}
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
edit_shapes(s, p, "shape.rotate", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
x.rot = ((r % 360.0) + 360.0) % 360.0;
}
Ok(())
})
}
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_shapes(s, p, "shape.flip", |sh| {
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
} else {
x.flip_h = !x.flip_h;
}
}
Ok(())
})
}
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_shapes(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom {
let defaults = slidecraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
while adj.len() <= i {
let k = adj.len();
adj.push(defaults.get(k).copied().unwrap_or(0.0));
}
if let Some(slot) = adj.get_mut(i) {
*slot = v;
}
}
Ok(())
})
}
fn change(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
let info = slidecraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
edit_shapes(s, p, "shape.change", |sh| {
sh.geom = Geom::preset(info.name);
Ok(())
})
}
fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
.as_array()?
.iter()
.filter_map(|s| {
let a = s.as_array()?;
Some(GradientStop { pos: a.first()?.as_f64()?.clamp(0.0, 1.0), color: color_value(a.get(1)?)? })
})
.collect();
if stops.len() < 2 {
return None;
}
let shape = match v.get("kind").and_then(Value::as_str) {
Some("radial" | "path") => GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] },
_ => GradientShape::Linear { angle: v.get("angle").and_then(Value::as_f64).unwrap_or(90.0), scaled: false },
};
Some(Gradient { stops, shape, rotate_with_shape: true })
}
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
let new_fill: Option<Option<Fill>> = if bool_or(p, "reset", false) {
Some(None)
} else if bool_or(p, "none", false) {
Some(Some(Fill::None))
} else if let Some(c) = color_param(p, "color") {
Some(Some(Fill::solid(c)))
} else if let Some(g) = p.get("gradient") {
Some(Some(Fill::Gradient(
gradient_param(g).ok_or_else(|| bad("shape.fill", "gradient needs at least two `stops`: [[0, \"#fff\"], [1, \"#000\"]]"))?,
)))
} else if let Some(pt) = p.get("pattern") {
Some(Some(Fill::Pattern(PatternFill {
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
fg: pt.get("fg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)),
bg: pt.get("bg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Bg1)),
})))
} else if p.get("picture").is_some() {
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "shape.fill")?;
let ct = super::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
Some(Some(Fill::Picture(PictureFill { media, ..Default::default() })))
} else {
None
};
let transparency = f64_param(p, "transparency");
if new_fill.is_none() && transparency.is_none() {
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`"));
}
// Transparency on an inherited (style) fill needs the effective colour.
let st = s.doc()?;
let ctx_fill: Vec<(slidecraft_model::ShapeId, Option<Fill>)> = {
let ctx = super::text::ctx_for(&st.doc, &st.selection);
targets(s, p)?
.into_iter()
.filter_map(|id| {
st.shape(id).map(|sh| {
(
id,
ctx.as_ref().and_then(|c| {
let (f, ph) = slidecraft_model::resolve::fill(c, sh);
f.map(|f| match f {
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
other => other,
})
}),
)
})
})
.collect()
};
edit_shapes(s, p, "shape.fill", |sh| {
if let Some(f) = &new_fill {
sh.fill.clone_from(f);
}
if let Some(t) = transparency {
let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone()));
if let Some(Fill::Solid { mut color }) = base {
color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
if t > 0.0 {
color.mods.push(ColorTransform::Alpha(((1.0 - t.clamp(0.0, 1.0)) * 100_000.0).round() as i32));
}
sh.fill = Some(Fill::Solid { color });
}
}
Ok(())
})
}
fn line_end(v: &str) -> Option<LineEnd> {
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
}
fn line(s: &mut Session, p: &Value) -> Result<Value> {
let reset = bool_or(p, "reset", false);
let none = bool_or(p, "none", false);
let color = color_param(p, "color");
let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0));
let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() });
let head = str_param(p, "head").map(line_end);
let tail = str_param(p, "tail").map(line_end);
let cap = str_param(p, "cap").map(|c| match c {
"round" => slidecraft_model::style::LineCap::Round,
"square" => slidecraft_model::style::LineCap::Square,
_ => slidecraft_model::style::LineCap::Flat,
});
let join = str_param(p, "join").map(|c| match c {
"bevel" => slidecraft_model::style::LineJoin::Bevel,
"miter" => slidecraft_model::style::LineJoin::Miter,
_ => slidecraft_model::style::LineJoin::Round,
});
edit_shapes(s, p, "shape.line", |sh| {
if reset {
sh.line = None;
return Ok(());
}
let l = sh.line.get_or_insert_with(Line::default);
if none {
l.fill = Some(Fill::None);
}
if let Some(c) = &color {
l.fill = Some(Fill::solid(c.clone()));
}
if width.is_some() {
l.width = width;
if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none {
l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)));
}
}
if dash.is_some() {
l.dash.clone_from(&dash);
}
if let Some(h) = &head {
l.head.clone_from(h);
}
if let Some(t) = &tail {
l.tail.clone_from(t);
}
if cap.is_some() {
l.cap = cap;
}
if join.is_some() {
l.join = join;
}
Ok(())
})
}
fn shadow_preset(v: &Value) -> Option<Option<Shadow>> {
let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a));
let base = |inner: bool| Shadow {
color: black(40000),
blur: 4.0,
dist: 3.0,
dir: 45.0,
inner,
sx: 1.0,
sy: 1.0,
kx: 0.0,
ky: 0.0,
align: "tl".into(),
rotate_with_shape: false,
};
match v {
Value::String(s) => Some(match s.as_str() {
"none" => None,
"inner" => Some(base(true)),
"perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }),
"bottom" => Some(Shadow { dir: 90.0, ..base(false) }),
"center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }),
_ => Some(base(false)),
}),
Value::Object(o) => Some(Some(Shadow {
color: o.get("color").and_then(color_value).unwrap_or(black(40000)),
blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0),
dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0),
dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0),
inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false),
..base(false)
})),
_ => None,
}
}
fn effects(s: &mut Session, p: &Value) -> Result<Value> {
let reset = bool_or(p, "reset", false);
let shadow = p.get("shadow").and_then(shadow_preset);
let glow: Option<Option<Glow>> = p.get("glow").map(|g| match g {
Value::Object(o) => Some(Glow {
color: o.get("color").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000))),
radius: o.get("radius").and_then(Value::as_f64).unwrap_or(8.0).clamp(0.0, 150.0),
}),
Value::Number(n) => Some(Glow {
color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)),
radius: n.as_f64().unwrap_or(8.0).clamp(0.0, 150.0),
}),
_ => None,
});
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
let refl = str_param(p, "reflection").map(|r| match r {
"none" => None,
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
});
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
edit_shapes(s, p, "shape.effects", |sh| {
if reset {
sh.effects = None;
return Ok(());
}
let e = sh.effects.get_or_insert_with(Effects::default);
if let Some(sw) = &shadow {
match sw {
Some(x) if x.inner => {
e.inner_shadow = Some(x.clone());
e.outer_shadow = None;
}
Some(x) => {
e.outer_shadow = Some(x.clone());
e.inner_shadow = None;
}
None => {
e.outer_shadow = None;
e.inner_shadow = None;
}
}
}
if let Some(g) = &glow {
e.glow.clone_from(g);
}
if let Some(sf) = soft {
e.soft_edge = sf;
}
if let Some(r) = &refl {
e.reflection.clone_from(r);
}
if let Some(b) = &bevel {
e.bevel.clone_from(b);
}
Ok(())
})
}
/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents.
pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>) {
let slots = [
SchemeSlot::Dk1,
SchemeSlot::Accent1,
SchemeSlot::Accent2,
SchemeSlot::Accent3,
SchemeSlot::Accent4,
SchemeSlot::Accent5,
SchemeSlot::Accent6,
];
let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1);
let row = index / 7;
let mut st = ShapeStyle::accent(slot);
let c = ColorRef::scheme(slot);
match row {
0 => {
// Colored outline, light fill.
st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
st.line_ref = (2, c);
st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1));
(st, None, None)
}
1 => (st, None, None),
2 => {
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000)));
st.line_ref = (0, c.clone());
st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000)));
(st, None, Some(Line::none()))
}
3 => {
st.fill_ref = (2, c);
(st, None, Some(Line::none()))
}
4 => {
st.fill_ref = (3, c.clone());
st.effect_ref = (3, c);
(st, None, Some(Line::none()))
}
_ => {
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000)));
st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
(st, None, None)
}
}
}
fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(1).min(41);
let (st, f, l) = quick_style_for(i);
edit_shapes(s, p, "shape.quickStyle", |sh| {
sh.style = Some(st.clone());
sh.fill.clone_from(&f);
sh.line.clone_from(&l);
sh.effects = None;
if let Some(t) = sh.text.as_mut() {
slidecraft_model::edit::format_all(t, &|r| r.fill = None);
}
Ok(())
})
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("shape.rename", "missing `name`"))?.to_string();
edit_shapes(s, p, "shape.rename", |sh| {
sh.name = name.clone();
Ok(())
})
}
fn alt_text(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_param(p, "text").unwrap_or("").to_string();
let deco = bool_param(p, "decorative");
edit_shapes(s, p, "shape.altText", |sh| {
sh.descr = text.clone();
if let Some(d) = deco {
sh.decorative = d;
}
Ok(())
})
}
fn visible(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "visible");
edit_shapes(s, p, "shape.visible", |sh| {
sh.hidden = !v.unwrap_or(sh.hidden);
Ok(())
})
}
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
let v = bool_param(p, "locked");
edit_shapes(s, p, "shape.lock", |sh| {
sh.locked = v.unwrap_or(!sh.locked);
Ok(())
})
}
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
// New shapes drawn in this session take this look (Set as Default Shape).
s.default_look = Some(super::format::Painted {
run: sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()),
para: None,
fill: sh.fill.clone(),
line: sh.line.clone(),
effects: sh.effects.clone(),
style: sh.style.clone(),
});
ok()
}
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
edit_shapes(s, p, "picture.crop", |sh| {
if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
for (i, v) in c.iter().enumerate() {
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
*slot = v.clamp(-10.0, 0.95);
}
}
}
Ok(())
})
}
fn picture_adjust(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, p, "picture.adjust", |sh| {
if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
let a = &mut fill.adjust;
if bool_or(p, "reset", false) {
*a = Default::default();
}
if let Some(v) = f64_param(p, "brightness") {
a.brightness = v.clamp(-1.0, 1.0);
}
if let Some(v) = f64_param(p, "contrast") {
a.contrast = v.clamp(-1.0, 1.0);
}
if let Some(v) = f64_param(p, "saturation") {
a.saturation = Some(v.clamp(0.0, 4.0));
}
if let Some(v) = bool_param(p, "grayscale") {
a.grayscale = v;
}
if let Some(v) = f64_param(p, "transparency") {
fill.alpha = Some((1.0 - v).clamp(0.0, 1.0));
}
}
Ok(())
})
}
fn picture_reset(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, p, "picture.reset", |sh| {
if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
fill.adjust = Default::default();
fill.crop = [0.0; 4];
fill.alpha = None;
}
sh.effects = None;
sh.line = None;
Ok(())
})
}
fn picture_change(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes) = super::insert::media_bytes(p, "picture.change")?;
let ct = super::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
edit_shapes(s, p, "picture.change", |sh| {
if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
fill.media = media;
fill.crop = [0.0; 4];
}
Ok(())
})
}
fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
edit_shapes(s, p, "media.options", |sh| {
if let slidecraft_model::ShapeKind::Media(m) = &mut sh.kind {
if let Some(v) = bool_param(p, "autoplay") {
m.autoplay = v;
}
if let Some(v) = bool_param(p, "loop") {
m.loop_play = v;
}
if let Some(v) = bool_param(p, "rewind") {
m.rewind = v;
}
if let Some(v) = bool_param(p, "acrossSlides") {
m.play_across_slides = v;
}
if let Some(v) = bool_param(p, "hide") {
m.hide_while_not_playing = v;
}
if let Some(v) = bool_param(p, "fullScreen") {
m.full_screen = v;
}
if let Some(v) = f64_param(p, "volume") {
m.volume = v.clamp(0.0, 1.0);
}
let ms = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| v.min(u32::MAX as u64) as u32);
if let Some(v) = ms("trimStart") {
m.trim_start_ms = v;
}
if let Some(v) = ms("trimEnd") {
m.trim_end_ms = v;
}
if let Some(v) = ms("fadeIn") {
m.fade_in_ms = v;
}
if let Some(v) = ms("fadeOut") {
m.fade_out_ms = v;
}
}
Ok(())
})?;
Ok(json!({}))
}
fn shape_id(p: &Value, key: &str) -> Option<slidecraft_model::ShapeId> {
p.get(key).and_then(Value::as_u64).map(|v| slidecraft_model::ShapeId(v as u32))
}
fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = shape_id(p, "id").ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
fn connect(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(from), Some(to)) = (shape_id(p, "from"), shape_id(p, "to")) else { return Err(bad("shape.connect", "missing `from` / `to`")) };
let st = s.doc()?;
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
if a.is_empty() || b.is_empty() {
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
}
let pick = |v: &[slidecraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len());
let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) {
(Some(i), Some(j)) => (i, j),
(fi, fj) => {
let mut best = (f64::MAX, 0, 0);
for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) {
for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) {
let d = (*pa - *pb).hypot();
if d < best.0 {
best = (d, i, j);
}
}
}
(best.1, best.2)
}
};
let (p0, p1) = (a[ia], b[ib]);
let id = match shape_id(p, "id") {
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
let r = s.execute("shape.insert", &json!({"preset": preset, "rect": [p0.x, p0.y, 1.0, 1.0]}))?;
slidecraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.connect", "insert failed"))? as u32)
}
};
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.connect", "no slide"))?;
let sh = slidecraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.connect", "no such line"))?;
if !sh.is_line() {
return Err(bad("shape.connect", "`id` is not a line or connector"));
}
sh.xfrm = Some(crate::tools::line_xfrm(p0, p1, 0.0));
sh.kind = slidecraft_model::ShapeKind::Connector { start: Some((from, ia as u32)), end: Some((to, ib as u32)) };
Ok(())
})?;
Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib}))
}