mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 03:17:28 -06:00
Shape Format: Shape Effects — shadow, glow and soft edges (#275)
Shapes and text boxes get an effects model (outer shadow with colour, transparency, blur, distance and angle; glow with colour, size and transparency; soft-edge radius), the shape.effects command (presets, custom values or null per effect, applied to every selected shape, undoable, hostile numbers clamped), screen and PDF rendering with the same banded approximation of a blur, DOCX a:effectLst read/write (outerShdw, glow, softEdge) with a matching wp:effectExtent, and a contextual Shape Format tab with Shape Fill, Shape Outline and Shape Effects (Shadow, Glow, Soft Edges submenus) plus Arrange. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40c5d51bf9
commit
2f8f304fc9
@@ -252,8 +252,17 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let obj =
|
||||
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
|
||||
let obj = InlineObject::Shape {
|
||||
kind,
|
||||
w,
|
||||
h,
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: 1.0,
|
||||
float: Float { wrap: Wrap::Inline, ..Default::default() },
|
||||
story: None,
|
||||
effects: Default::default(),
|
||||
};
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
sel_result(s)
|
||||
@@ -275,6 +284,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
|
||||
stroke_width: 0.75,
|
||||
float: Float::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
};
|
||||
s.doc.insert_object(&at, obj, &props)?;
|
||||
// Like Word, type straight into the new box.
|
||||
|
||||
@@ -294,6 +294,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects)
|
||||
.params(
|
||||
r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#,
|
||||
)
|
||||
.when(has_shape),
|
||||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
with_obj(s, |o| {
|
||||
@@ -320,7 +325,15 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
|
||||
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
|
||||
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
|
||||
/// caret.
|
||||
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = s.sel.ordered();
|
||||
let max = if a == b { 1 } else { max };
|
||||
let story = a.story;
|
||||
let paths = if a == b { vec![a.path.clone()] } else { s.doc.paths_between(&a, &b) };
|
||||
for path in paths {
|
||||
@@ -335,11 +348,97 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
continue;
|
||||
}
|
||||
if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) {
|
||||
return Some((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
out.push((Pos { story, path: path.clone(), off }, o.clone()));
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
out
|
||||
}
|
||||
|
||||
/// Most shapes one `shape.effects` changes.
|
||||
const MAX_EFFECT_SHAPES: usize = 10_000;
|
||||
|
||||
/// `shape.effects`: set or clear the shadow, glow and soft edges of the selected shapes.
|
||||
fn shape_effects(s: &mut Session, v: &Value) -> CmdResult {
|
||||
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
|
||||
let color = |o: &Value| p::str(o, "color").and_then(Rgb::parse);
|
||||
// Each change is `None` (leave as it is) or `Some(new value)`.
|
||||
let shadow: Option<Option<Shadow>> = match v.get("shadow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Bool(b)) => Some(b.then(Shadow::default)),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(Value::String(id)) => Some(Some(Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?)),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let mut sh = match p::str(o, "preset") {
|
||||
Some(id) => Shadow::preset(id).ok_or_else(|| CmdError::Params(format!("unknown shadow preset `{id}`")))?,
|
||||
None => Shadow::default(),
|
||||
};
|
||||
sh.color = color(o).unwrap_or(sh.color);
|
||||
sh.transparency = p::f32(o, "transparency").unwrap_or(sh.transparency);
|
||||
sh.blur = p::f32(o, "blur").unwrap_or(sh.blur);
|
||||
sh.distance = p::f32(o, "distance").unwrap_or(sh.distance);
|
||||
sh.angle = p::f32(o, "angle").unwrap_or(sh.angle);
|
||||
Some(Some(sh.sanitized()))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`shadow`: a preset name, an object or null".into())),
|
||||
};
|
||||
let glow: Option<Option<Glow>> = match v.get("glow") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(id)) if id == "none" => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(Some(Glow { size: n.as_f64().unwrap_or(0.0) as f32, ..Glow::default() }.sanitized())),
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let d = Glow::default();
|
||||
Some(Some(
|
||||
Glow {
|
||||
color: color(o).unwrap_or(d.color),
|
||||
size: p::f32(o, "size").unwrap_or(d.size),
|
||||
transparency: p::f32(o, "transparency").unwrap_or(d.transparency),
|
||||
}
|
||||
.sanitized(),
|
||||
))
|
||||
}
|
||||
Some(_) => return Err(CmdError::Params("`glow`: a size in points, an object or null".into())),
|
||||
};
|
||||
let soft: Option<Option<f32>> = match v.get("softEdge") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(n @ Value::Number(_)) => Some(n.as_f64().map(|x| x as f32)),
|
||||
Some(_) => return Err(CmdError::Params("`softEdge`: a radius in points or null".into())),
|
||||
};
|
||||
let targets: Vec<Pos> =
|
||||
selected_all(s, MAX_EFFECT_SHAPES).into_iter().filter(|(_, o)| matches!(o, InlineObject::Shape { .. })).map(|(pos, _)| pos).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(CmdError::Disabled("no shape selected".into()));
|
||||
}
|
||||
let mut last = ShapeEffects::default();
|
||||
for pos in &targets {
|
||||
let o = edit_obj(s, pos, |o| {
|
||||
if let InlineObject::Shape { effects, float, .. } = o {
|
||||
let mut e = *effects;
|
||||
if let Some(x) = shadow {
|
||||
e.shadow = x;
|
||||
}
|
||||
if let Some(x) = glow {
|
||||
e.glow = x;
|
||||
}
|
||||
if let Some(x) = soft {
|
||||
e.soft_edge = x;
|
||||
}
|
||||
*effects = e.sanitized();
|
||||
// Room around the shape for its shadow and glow, as Word's effect extent.
|
||||
float.effect = effects.extent();
|
||||
}
|
||||
})?;
|
||||
if let InlineObject::Shape { effects, .. } = o {
|
||||
last = effects;
|
||||
}
|
||||
}
|
||||
Ok(json!({"shapes": targets.len(), "effects": serde_json::to_value(last).unwrap_or(Value::Null)}))
|
||||
}
|
||||
|
||||
/// Smallest width/height an object can be resized to, points (a text box keeps room for a line).
|
||||
|
||||
@@ -1700,3 +1700,42 @@ fn timestamps_come_from_the_clock() {
|
||||
// Fixed-width ISO strings sort by time.
|
||||
assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}");
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
|
||||
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
|
||||
#[test]
|
||||
fn shape_effects_apply_and_undo() {
|
||||
use wordcraft_doc::effects::{MAX_BLUR, MAX_DISTANCE, Shadow};
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "ellipse"}));
|
||||
let effects = |s: &Session| match cmd::objects::selected(s) {
|
||||
Some((_, wordcraft_doc::InlineObject::Shape { effects, float, .. })) => (effects, float.effect),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
assert!(effects(&s).0.is_empty());
|
||||
run(&mut s, "shape.effects", json!({"shadow": "offsetBottomRight", "glow": {"color": "FF0000", "size": 8}, "softEdge": 5}));
|
||||
let (e, extent) = effects(&s);
|
||||
assert_eq!(e.shadow, Shadow::preset("offsetBottomRight"));
|
||||
assert_eq!(e.glow.map(|g| (g.color, g.size)), Some((wordcraft_doc::props::Rgb(255, 0, 0), 8.0)));
|
||||
assert_eq!(e.soft_edge, Some(5.0));
|
||||
assert!(extent.iter().all(|v| *v >= 8.0), "room for the glow: {extent:?}");
|
||||
// Clearing the glow leaves the shadow and soft edges.
|
||||
run(&mut s, "shape.effects", json!({"glow": null}));
|
||||
let (e, _) = effects(&s);
|
||||
assert!(e.glow.is_none() && e.shadow.is_some() && e.soft_edge == Some(5.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(effects(&s).0.glow.map(|g| g.size), Some(8.0));
|
||||
// Hostile numbers.
|
||||
run(&mut s, "shape.effects", json!({"shadow": {"blur": 1e30, "distance": -5, "angle": 1e20, "transparency": 400}, "softEdge": -3}));
|
||||
let (e, _) = effects(&s);
|
||||
let sh = e.shadow.unwrap();
|
||||
assert!(sh.blur == MAX_BLUR && sh.distance == 0.0 && (0.0..360.0).contains(&sh.angle) && sh.transparency == 100.0, "{sh:?}");
|
||||
assert!(e.soft_edge.is_none());
|
||||
run(&mut s, "shape.effects", json!({"shadow": {"distance": 1e9}}));
|
||||
assert_eq!(effects(&s).0.shadow.map(|s| s.distance), Some(MAX_DISTANCE));
|
||||
assert!(s.run("shape.effects", &json!({"shadow": "perspective"})).is_err());
|
||||
// Off a shape it's disabled.
|
||||
run(&mut s, "select.collapse", json!({"end": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
assert!(s.run("shape.effects", &json!({"shadow": null})).is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user