Files
vectorcraft/crates/engine/src/cmd/appearance.rs
T
dotCooCoo c969b7857d Type: an Eyedropper pick on a selected story styles every frame
appearance.copyFrom and a fill or stroke change on a story selected by
Select All no longer skip locked frames, hidden frames and frames on a
locked artboard. They apply to every frame of the story, as the Character
panel's story edits do. appearance.copyFrom still leaves out a frame that
is its source.
2026-11-07 05:15:11 +07:00

1329 lines
65 KiB
Rust

//! The Appearance panel: fill and stroke items, the active item that paint, stroke, gradient,
//! transparency and effect edits target, live effects, Clear / Reduce to Basic, and the
//! Eyedropper's appearance copy.
//!
//! **Item targeting.** Commands that edit a fill or stroke take `item?`: a paint-order index into
//! the appearance stack (`items[0]` is painted first, the bottom row of the panel). Omitted, they
//! edit the Appearance panel's active item (`appearance.setActiveItem`) when one is set for the
//! current selection and no `ids` are given, else the topmost fill or stroke; `null` always means
//! the topmost one. A targeted item lives in the selected objects' own stacks (a group's own fill,
//! not its contents'), so item edits apply to the selected objects themselves.
//!
//! **Object or contents.** The `appearance.*` and `effect.*` commands edit the stacks of the
//! selected objects themselves (`target: "object"`, the default; with `ids`, layers too) or, with
//! `target: "contents"`, of the painted objects inside the selected groups and layers. A group's or
//! layer's own fills and strokes paint its members' geometry and its effects apply to them as one
//! piece; the Contents row (type: Characters) is the slot that says which of its fills and strokes
//! paint below the members and which above ([`Appearance::contents_at`], moved by
//! `appearance.moveItem {from: "contents"}`). An image's own fills and strokes paint only the
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
//! image as its Contents row says; new ones go right below that row.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::appearance::SharedStack;
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, FillLayer, Node, NodeKind, ParaStyle, StrokeLayer};
use vectorcraft_geom::Rect;
use super::edit::{outermost, roots_of, selected_roots};
use super::opacitymask::percent;
use super::paint::paint_from;
use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs};
use super::{
CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets,
ok, paint_targets, str_param, switch_or, targets,
};
use crate::EngineError;
/// The Appearance panel's commands: fills and strokes, their order and copies, and the active item.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"appearance.addFill",
"Add New Fill",
["Window", "Appearance"],
Some("Cmd+/"),
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row",
has_doc,
|s, p| add_item(s, p, true)
),
cmd!(
"appearance.addStroke",
"Add New Stroke",
["Window", "Appearance"],
Some("Cmd+Alt+/"),
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt",
has_doc,
|s, p| add_item(s, p, false)
),
cmd!(
"appearance.clear",
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode",
has_doc,
clear_appearance
),
cmd!(
"appearance.reduceToBasic",
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
has_doc,
reduce_basic
),
cmd!(
"appearance.setItem",
"Appearance Item",
[],
None,
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack",
has_doc,
set_item
),
cmd!(
"appearance.removeItem",
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack",
has_doc,
remove_item
),
cmd!(
"appearance.addEffect",
"Add Effect",
[],
None,
"{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}",
has_doc,
super::effectcmd::apply
),
cmd!(
"appearance.duplicateItem",
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them",
has_doc,
duplicate_item
),
cmd!(
"appearance.moveItem",
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
cmd!(
"appearance.copyFrom",
"Eyedropper",
[],
None,
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
has_doc,
copy_from
),
cmd!(
"appearance.setActiveItem",
"Select Appearance Item",
[],
None,
"{index: paint-order item index in the first selected object's stack | null} make that fill/stroke row the target of the paint.setFill/setStroke, stroke.set/setAdvanced, paint.editGradient/setGradientGeom, transparency.set and effect.* calls that omit `item` (a fill row brings the Fill proxy forward, a stroke row the Stroke proxy); null or any selection change clears it → {index}",
has_selection,
set_active_item
),
cmd!(
"appearance.showAllHidden",
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden → {ids}",
has_doc,
show_all_hidden
),
cmd!(
"appearance.targetContents",
"Target Contents",
[],
None,
"{ids?|id?, target?: \"object\"|\"contents\"} select the members of the selected groups and layers (or `ids`), as double-clicking the Appearance panel's Contents row does: the panel then lists, and appearance edits change, their own appearance; errors when none has members → {ids}",
has_doc,
target_contents
),
cmd!(
"appearance.transfer",
"Move Appearance",
[],
None,
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds → {source, target}",
has_doc,
transfer
),
]
}
/// The Appearance panel's active row, remembered for the selection it was chosen in.
#[derive(Clone, Debug)]
pub(crate) struct ActiveItem {
doc: u64,
objects: Vec<NodeId>,
index: usize,
}
impl Session {
/// The method returns explicit target parameters and the selected appearance effect list.
pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> {
let mut ids = appearance_targets(self, params).ok()?;
let item = item_target(self, params, "effect.apply").ok()?;
let anchor = item.anchor(self, &ids);
let node = self.active()?.doc.node(*ids.get(anchor)?)?;
let at = item.effects_item(&node.appearance, "effect.apply").ok()?;
let effects = node.appearance.effects_at(at)?;
ids.swap(0, anchor);
let ids: Vec<u64> = ids.into_iter().map(|id| id.0).collect();
let aligned = match item {
ItemTarget::Item { aligned, .. } => aligned,
ItemTarget::Top => aligned(params, "effect.apply").ok()?,
};
Some((json!({"ids": ids, "item": at, "aligned": aligned}), effects.as_slice()))
}
/// The Appearance panel's active fill/stroke: a paint-order index into the first selected
/// object's stack, while the selection it was chosen for is unchanged.
pub fn appearance_item(&self) -> Option<usize> {
let a = self.active_appearance_item.as_ref()?;
let st = self.active()?;
if st.uid != a.doc || st.selection.subjects() != a.objects.as_slice() {
return None;
}
let n = st.doc.node(*a.objects.first()?)?;
(a.index < n.appearance.items.len()).then_some(a.index)
}
/// Re-point the active item after an edit of `ids` changed its stack (`None` drops it). Edits
/// of other objects leave it alone.
fn remap_appearance_item(&mut self, ids: &[NodeId], f: impl FnOnce(usize) -> Option<usize>) {
if let Some(a) = &mut self.active_appearance_item
&& a.objects.first().is_some_and(|o| ids.contains(o))
{
match f(a.index) {
Some(i) => a.index = i,
None => self.active_appearance_item = None,
}
}
}
}
/// Which fill or stroke of an appearance stack an edit changes (the `item` param).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ItemTarget {
/// The topmost fill or stroke (for effects: the object-level effects).
Top,
/// Item `index`. `explicit`: named by the `item` param, so it must exist and be of the edited
/// kind; otherwise it is the panel's active item, which applies only where it fits. `aligned`:
/// `index` is a row of the first target's stack, and each other target edits the item equal to
/// it there ([`ItemTarget::per_target`]): the active item always is (it is a row of the first
/// selected object's stack), an explicit one with `aligned: true`.
Item { index: usize, explicit: bool, aligned: bool },
}
/// Parse the `item` param of `cmd` (see the module docs).
pub(crate) fn item_target(s: &Session, p: &Value, cmd: &str) -> Result<ItemTarget> {
item_target_at(s, p, "item", cmd)
}
/// [`item_target`] read from param `key` (e.g. `fromItem`).
pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<ItemTarget> {
let aligned = aligned(p, cmd)?;
match p.get(key) {
Some(Value::Null) => Ok(ItemTarget::Top),
Some(v) => v
.as_u64()
.map(|i| ItemTarget::Item { index: index(i), explicit: true, aligned })
.ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))),
// The active item is a row of the first selected object's own stack.
None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top),
None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })),
}
}
impl ItemTarget {
/// Objects a fill/stroke edit changes: the selected objects' own stacks when an item is
/// targeted, else the painted leaves ([`paint_targets`]).
pub(crate) fn targets(self, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match self {
ItemTarget::Top => paint_targets(s, p),
ItemTarget::Item { .. } => appearance_targets(s, p),
}
}
/// The target of a fill (`fill`) or stroke edit: the panel's active item stands only for edits
/// of its own kind, so a fill row leaves stroke edits on the topmost stroke of the painted
/// leaves (as without an active item) and vice versa. Explicit items are kept (and checked).
pub(crate) fn of_kind(self, s: &Session, fill: bool) -> Self {
let ItemTarget::Item { index, explicit: false, .. } = self else { return self };
let fits =
s.active().and_then(|st| st.doc.node(*st.selection.subjects().first()?)?.appearance.items.get(index).map(|it| it.is_fill() == fill));
if fits == Some(true) { self } else { ItemTarget::Top }
}
/// This target for each of `ids` (in their order): an aligned item (a row of the first
/// target's stack, or for the active item of the first selected object's, see
/// [`ItemTarget::anchor`]) is, in each other target, the item equal to it there (see
/// [`SharedStack`]), each target matched against that stack on its own, whatever the other
/// targets have. In a target without an equal item, and for any other target, the row stands
/// for itself. Every entry is `Some`.
pub(crate) fn per_target(self, s: &Session, ids: &[NodeId]) -> Vec<Option<ItemTarget>> {
let ItemTarget::Item { index, explicit, aligned: true } = self else { return vec![Some(self); ids.len()] };
let Some(st) = s.active().filter(|_| ids.len() >= 2) else { return vec![Some(self); ids.len()] };
let k = self.anchor(s, ids);
let Some(first) = ids.get(k).and_then(|id| st.doc.node(*id)) else { return vec![Some(self); ids.len()] };
ids.iter()
.enumerate()
.map(|(j, id)| match st.doc.node(*id) {
Some(n) if j != k => {
let at = SharedStack::of(&[&*first.appearance, &*n.appearance]).item_in(index, 1);
Some(at.map_or(self, |index| ItemTarget::Item { index, explicit, aligned: true }))
}
_ => Some(self),
})
.collect()
}
/// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of
/// is: the first selected object (or the compound path holding it) for the active item, which
/// need not paint first; the first target otherwise, or when that object is not among them.
pub(crate) fn anchor(self, s: &Session, ids: &[NodeId]) -> usize {
let ItemTarget::Item { explicit: false, .. } = self else { return 0 };
let Some(st) = s.active() else { return 0 };
let Some(first) = st.selection.subjects().first() else { return 0 };
let root = roots_of(&st.doc, vec![*first]).first().copied().unwrap_or(*first);
ids.iter().position(|id| *id == root).unwrap_or(0)
}
/// The item of `ap` a fill (`fill`) or stroke edit changes; `None` = the topmost one.
pub(crate) fn resolve(self, ap: &Appearance, fill: bool, cmd: &str) -> Result<Option<usize>> {
match self {
ItemTarget::Top => Ok(None),
ItemTarget::Item { index, explicit, .. } => match ap.item_of_kind(Some(index), fill) {
None if explicit => Err(bad(
cmd,
match ap.items.get(index) {
None => format!("no appearance item {index}"),
Some(_) => format!("appearance item {index} is not a {}", if fill { "fill" } else { "stroke" }),
},
)),
found => Ok(found),
},
}
}
/// The item whose own effects an effect edit changes; `None` = the object-level effects.
pub(crate) fn effects_item(self, ap: &Appearance, cmd: &str) -> Result<Option<usize>> {
match self {
ItemTarget::Top => Ok(None),
ItemTarget::Item { index, .. } if index < ap.items.len() => Ok(Some(index)),
ItemTarget::Item { index, explicit: true, .. } => Err(bad(cmd, format!("no appearance item {index}"))),
ItemTarget::Item { explicit: false, .. } => Ok(None),
}
}
}
/// Does `p` say its item and effect indices are rows of the first target's stack (`aligned`),
/// which each other target edits where it has them (see [`ItemTarget::per_target`])?
pub(crate) fn aligned(p: &Value, cmd: &str) -> Result<bool> {
switch_or(p, "aligned", false, cmd)
}
/// What the appearances of `ids` share, when there are several of them and they all exist.
pub(crate) fn shared_stack(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Option<SharedStack> {
if ids.len() < 2 {
return None;
}
let aps: Vec<&Appearance> = ids.iter().map(|id| d.node(*id).map(|n| &*n.appearance)).collect::<Option<_>>()?;
Some(SharedStack::of(&aps))
}
/// Do the appearances of `ids` differ (several objects whose stacks aren't all equal)?
pub(crate) fn appearances_differ(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> bool {
let mut aps = ids.iter().filter_map(|id| d.node(*id)).map(|n| &n.appearance);
let Some(first) = aps.next() else { return false };
aps.any(|a| a != first)
}
/// Indices `idx` of the first target's items (aligned with `aligned`) in each of `ids`: the first
/// target's own, and in each other one the items equal to them there (see [`SharedStack`]), each
/// index and each target on its own: an index a target doesn't share with the first is left out
/// of that target (never taken as the same index there), whatever the other targets have. Not
/// aligned, or one target: the same indices in each.
pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec<Vec<usize>> {
let aps: Option<Vec<&Appearance>> = ids.iter().map(|id| d.node(*id).map(|n| &*n.appearance)).collect();
match aps.filter(|aps| aligned && aps.len() >= 2) {
Some(aps) => aps
.iter()
.enumerate()
.map(|(k, ap)| match aps.first() {
Some(first) if k > 0 => {
let sh = SharedStack::of(&[*first, *ap]);
idx.iter().filter_map(|i| sh.item_in(*i, 1)).collect()
}
_ => idx.to_vec(),
})
.collect(),
None => vec![idx.to_vec(); ids.len()],
}
}
/// Whether the `target` param asks for the contents of groups and layers (see the module docs).
fn targets_contents(p: &Value) -> Result<bool> {
match p.get("target") {
None | Some(Value::Null) => Ok(false),
Some(v) => match v.as_str() {
Some("object") => Ok(false),
Some("contents") => Ok(true),
_ => Err(bad("appearance", format!("`target` must be \"object\" or \"contents\", not {v}"))),
},
}
}
/// The objects appearance edits act on without `ids`: the targeted object, group or layer
/// (`layer.target`), else the selected objects (a selected compound-path member stands for its
/// compound).
pub(crate) fn subject_roots(s: &Session) -> Result<Vec<NodeId>> {
match s.doc()?.selection.target {
Some(t) => Ok(vec![t]),
None => selected_roots(s),
}
}
/// Objects whose own appearance stack a command edits: `ids`/`id` (layers too; an id inside
/// another given one is dropped), else the [`subject_roots`]; with `target: "contents"` the painted
/// objects inside the groups and layers among them ([`leaf_targets`]).
pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let roots = if p.get("ids").is_none() && p.get("id").is_none() {
subject_roots(s)?
} else {
let d = &s.doc()?.doc;
outermost(d, targets(s, p)?.into_iter().filter(|id| d.node(*id).is_some()).collect())
};
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
/// [`appearance_targets`] of `cmd`, which needs at least one.
fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select objects or give ids"));
}
Ok(ids)
}
/// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke
/// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters).
/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and
/// its undo history stay as they are.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
item: ItemTarget,
cmd: &str,
label: &str,
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false))
}
/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns
/// whether it changed the document; when it did not and `f` left every target as it was, the
/// document and its undo history stay as they are.
#[allow(clippy::too_many_arguments)]
pub(crate) fn edit_items_then(
s: &mut Session,
ids: &[NodeId],
item: ItemTarget,
cmd: &str,
label: &str,
fill: bool,
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
let before = s.doc()?.doc.clone();
let per = item.per_target(s, ids);
super::overprint::edit_counted(s, label, |d| {
let mut edited = Vec::new();
for (id, item) in ids.iter().zip(&per) {
let Some(item) = item else { continue };
let Some(n) = d.node_mut(*id) else { continue };
let index = item.resolve(&n.appearance, fill, cmd)?;
f(n, index)?;
edited.push(*id);
}
let more = after(d, &edited)?;
Ok(count_changed(d, &before, &edited) + usize::from(more))
})?;
Ok(())
}
/// How many of `ids` differ in `d` from `before`.
pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_doc::Document, ids: &[NodeId]) -> usize {
ids.iter().filter(|id| d.node(**id) != before.node(**id)).count()
}
/// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the
/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or
/// null is an error of command `cmd`.
pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result<bool> {
check_switches(p, &["stroke"], cmd)?;
if let Some(b) = p.get("stroke").and_then(Value::as_bool) {
return Ok(b);
}
let ItemTarget::Item { index, .. } = item else { return Ok(default) };
let ids = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill()))
}
/// The whole number of 0 or more that `key` holds, read with [`index`].
pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.setActiveItem";
let index = match p.get("index") {
Some(Value::Null) => {
s.active_appearance_item = None;
return Ok(json!({ "index": Value::Null }));
}
Some(_) => index_param(p, "index", C)?,
None => return Err(bad(C, "missing `index` (an item index or null)")),
};
let st = s.doc()?;
let objects = st.selection.subjects().to_vec();
let first = objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| EngineError::Other("nothing selected".into()))?;
let fill = first.appearance.items.get(index).ok_or_else(|| bad(C, format!("no appearance item {index}")))?.is_fill();
let doc = st.uid;
s.fill_active = fill;
s.active_appearance_item = Some(ActiveItem { doc, objects, index });
Ok(json!({ "index": index }))
}
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
let defaults = s.paint.clone();
// The panel's active Fill row, when the new fill goes on the selection: the new fill takes that
// row's paint, right above it in each target that has it (others take their top fill's paint).
// Where the new item went in on the active item's object, when it is an image.
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
// The active row is a row of the active object's stack, which need not come first in `ids`
// (they are in paint order): match the others against it.
let anchor = active.and_then(|a| ids.iter().position(|id| *id == a));
let selected = match (s.appearance_item(), anchor) {
(Some(index), Some(_)) if fill && p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => {
ItemTarget::Item { index, explicit: false, aligned: true }.of_kind(s, true)
}
_ => ItemTarget::Top,
};
let per: Vec<Option<usize>> = match (selected, anchor) {
(ItemTarget::Item { index, .. }, Some(k)) => {
let mut order = ids.clone();
order.swap(0, k);
let mut per: Vec<Option<usize>> =
items_per_target(&s.doc()?.doc, &order, &[index], true).into_iter().map(|v| v.first().copied()).collect();
per.swap(0, k);
per
}
_ => vec![None; ids.len()],
};
let before = s.doc()?.doc.clone();
let mut inserted = None;
// Where the new fill went in on the active item's object, when it went above the active row.
let mut above = None;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for (id, row) in ids.iter().zip(&per) {
if let Some(n) = d.node_mut(*id) {
if let Some(i) = n.appearance.item_of_kind(*row, true)
&& let Some(paint) = n.appearance.paint_at(Some(i), true).cloned()
{
n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint)));
if active == Some(*id) {
above = Some(i + 1);
}
continue;
}
// The top fill's (stroke's) paint; without one, type's characters' and otherwise the
// default paint, so a new fill on a group or type shows. A new stroke takes the top
// stroke's weight when that stroke has paint, at least 1 pt.
let paint = match (n.appearance.paint_at(None, fill), &n.kind) {
(Some(p), _) => p.clone(),
(None, NodeKind::Text(_)) => super::paint::proxy_paint(n, !fill, None),
(None, _) => {
if fill {
defaults.fill.clone()
} else {
defaults.stroke.clone()
}
}
};
let item = if fill {
AppearanceItem::Fill(FillLayer::new(paint))
} else {
AppearanceItem::Stroke(StrokeLayer::new(paint, n.appearance.stroke_width().max(1.0)))
};
if matches!(n.kind, NodeKind::Image(_)) {
// On an image it goes right below the Contents row (the image).
let k = n.appearance.contents_at();
n.appearance.items.insert(k, item);
n.appearance.set_contents_at(k + 1);
if active == Some(*id) {
inserted = Some(k);
}
} else {
n.appearance.items.push(item);
}
}
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(())
})?;
// A fill added above the active row becomes the active row; otherwise the rows above the new
// item move up by one.
s.remap_appearance_item(&ids, |a| above.or(Some(a + usize::from(inserted.is_some_and(|k| k <= a)))));
ok()
}
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let ids = require_targets(s, p, "appearance.clear")?;
// When every object is cleared already, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Clear Appearance", |d| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
clear(n);
}
}
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
s.active_appearance_item = None;
ok()
}
/// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an
/// edit that replaces whole stacks.
fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> {
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect()
}
/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost row has another opacity in `d` than
/// in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`].
fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> {
let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| {
d.node(id).and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill).map(AppearanceItem::opacity)).unwrap_or(1.0)
};
ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect()
}
/// Clear Appearance on `n`: no fill, no stroke, and the object's Opacity row back to Default. A
/// group keeps no fill or stroke rows of its own.
fn clear(n: &mut Node) {
n.appearance = if is_group(n) { Appearance::default().into() } else { Appearance::basic(Paint::None, Paint::None, 1.0).into() };
n.opacity = 1.0;
n.blend = BlendMode::Normal;
}
fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.transfer";
let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?;
if source == target {
return Err(bad(C, "`source` and `target` are the same object"));
}
let copy = switch_or(p, "copy", false, C)?;
// When both objects come out as they were, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
let mut ap = src.appearance.clone();
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
ap.rebase_gradients(f, t);
}
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
if to.contents_label().is_none() {
ap.contents_index = None;
}
to.appearance = ap;
(to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
if !copy && let Some(n) = d.node_mut(source) {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
}
let changed = if copy { vec![target] } else { vec![source, target] };
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
}
/// A group or layer (no fill or stroke rows of its own unless added).
fn is_group(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
// When every object comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
// The Appearance panel's active row (a row of the first selected object's stack, and in each
// other target the row equal to it there), when the command acts on that selection. The targets
// are in paint order, so the first selected object can be any of them.
let first = s.doc()?.selection.subjects().first().and_then(|f| ids.iter().position(|id| id == f));
let active = match (s.appearance_item(), first) {
(Some(index), Some(k)) if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => {
let mut from = ids.clone();
from.swap(0, k);
let mut rows = items_per_target(&before, &from, &[index], true);
rows.swap(0, k);
rows
}
_ => vec![Vec::new(); ids.len()],
};
super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| {
for (id, at) in ids.iter().zip(&active) {
if let Some(n) = d.node_mut(*id) {
// The active row stays when it is a visible fill or stroke; otherwise the topmost
// visible one of each kind does. Both lose their own transparency and effects.
let picked = at.first().and_then(|i| n.appearance.items.get(*i)).filter(|i| i.visible()).cloned();
let top = |fill: bool| match &picked {
Some(i) if i.is_fill() == fill => Some(i.clone()),
_ => n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(),
};
let (fill, stroke) = (top(true), top(false));
// Other objects always keep a fill row (None when there was none); a group only
// keeps the rows it had.
let fill =
(fill.is_some() || !is_group(n)).then(|| AppearanceItem::Fill(FillLayer::new(fill.map_or(Paint::None, |f| f.paint().clone()))));
n.appearance = Appearance { items: fill.into_iter().collect(), ..Default::default() }.into();
if let Some(AppearanceItem::Stroke(mut st)) = stroke {
st.effects.clear();
st.opacity = 1.0;
st.blend = BlendMode::Normal;
n.appearance.items.push(AppearanceItem::Stroke(st));
}
}
}
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
s.active_appearance_item = None;
ok()
}
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = index_param(p, "index", "appearance.setItem")?;
super::paint::check_keep_model(p, "appearance.setItem")?;
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
check_switches(p, &["visible"], "appearance.setItem")?;
let paint = paint_from(s, p)?;
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
return Err(bad("appearance.setItem", "select objects or give ids"));
}
let blend = str_param(p, "blend").and_then(BlendMode::parse);
// When every object comes out as it was (for example, when none has item `index`), the document
// and its undo history stay as they are.
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?);
super::overprint::edit_counted(s, "Appearance", |d| {
let mut edited = Vec::new();
for (id, at) in ids.iter().zip(&per) {
let Some(&idx) = at.first() else { continue };
let Some(n) = d.node_mut(*id) else { continue };
let items = &n.appearance.items;
if let Some(fill) = items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f) == Some(idx)) {
edited.push((*id, fill));
}
if p.get("weight").is_none()
&& let Some(pa) = &paint
{
super::paint::restore_unpainted_stroke(&mut n.appearance, Some(idx), pa);
}
let bounds = n.geometric_bounds();
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
let (pp, op, bl, vis, bounds) = match item {
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible, bounds),
AppearanceItem::Stroke(st) => {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
let bounds = bounds.map(|b| st.paint_bounds(b));
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible, bounds)
}
};
if let Some(pa) = &paint {
*pp = super::gradient::place_paint(&super::paint::keep_pattern_xf(pa, p, Some(&*pp)), p, bounds);
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = percent(o);
}
if let Some(b) = blend {
*bl = b;
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
*vis = v;
}
}
// The graph keeps an opacity set on the topmost fill or stroke of its series art for when it is drawn again.
let kept = p.get("opacity").and_then(Value::as_f64).is_some() && super::graph::capture_series_opacity(d, &edited);
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
if let Some(pa) = &paint {
s.remember_paint(pa);
}
ok()
}
/// The paint-order item indices a command acts on (`indices`, else `index`), ascending and
/// without repeats.
pub(super) fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let mut v = match p.get("indices") {
Some(a) => a
.as_array()
.and_then(|a| a.iter().map(|x| x.as_u64().map(index)).collect::<Option<Vec<_>>>())
.filter(|v| !v.is_empty())
.ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?,
None => vec![index_param(p, "index", cmd)?],
};
v.sort_unstable();
v.dedup();
Ok(v)
}
/// `appearance.removeItem`: removes fill/stroke `index`, or every one of `indices`, from the selected objects in one undo step.
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = item_indices(p, "appearance.removeItem")?;
let ids = require_targets(s, p, "appearance.removeItem")?;
// When no object has an item at any of the indices, the document and its undo history stay as
// they are.
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?);
super::overprint::edit_counted(s, "Remove Item", |d| {
for (id, idx) in ids.iter().zip(&per) {
if let Some(n) = d.node_mut(*id) {
let mut idx = idx.clone();
idx.sort_unstable();
// From the top down, so the lower indices stay valid.
for &i in idx.iter().rev() {
n.appearance.remove_item(i);
}
}
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
ok()
}
/// `appearance.duplicateItem`: copies fill/stroke `index`, or the rows `indices` as one block at `to` (with the Contents row when `contents` says so), in one undo step.
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.duplicateItem";
let idx = item_indices(p, C)?;
let to = p.get("to").map(|_| index_param(p, "to", C)).transpose()?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
if contents.is_some() && to.is_none() {
return Err(bad(C, "`contents` goes with `to`"));
}
let ids = require_targets(s, p, C)?;
// Where the first target's copy landed, and where it landed on the active row's object (the
// first selected one, not always the first target): copies placed at `to` land at a different
// index on stacks of other lengths.
let mut first_at = None;
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut active_at = None;
// Aligned rows of a mixed selection are copied right above themselves in each object.
if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
}
let before = s.doc()?.doc.clone();
let per = items_per_target(&before, &ids, &idx, aligned(p, C)?);
s.edit("Duplicate Item", |d, _| {
let mut any = false;
for (id, idx) in ids.iter().zip(&per) {
let Some(n) = d.node_mut(*id) else { continue };
let has_contents = n.contents_label().is_some();
let ap = &mut n.appearance;
if let Some(t) = to {
// The copies land together at `to`, in their order; every item must exist.
if idx.iter().any(|i| *i >= ap.items.len()) {
return Err(bad(C, format!("no item at one of the indices {idx:?}")));
}
let copies: Vec<_> = idx.iter().filter_map(|&i| ap.items.get(i).cloned()).collect();
let at = t.min(ap.items.len());
first_at.get_or_insert(at);
if active == Some(*id) {
active_at = Some(at);
}
for (k, item) in copies.into_iter().enumerate() {
ap.insert_item(at + k, item);
}
if let Some(c) = contents.filter(|_| has_contents) {
ap.set_contents_at(c);
}
any = true;
continue;
}
let mut idx = idx.clone();
idx.sort_unstable();
// From the top down, so the lower indices stay valid.
for &i in idx.iter().rev() {
if let Some(item) = ap.items.get(i).cloned() {
first_at.get_or_insert(i + 1);
ap.insert_item(i + 1, item);
any = true;
}
}
}
if !any {
return Err(bad(C, format!("no item at index {}", idx[0])));
}
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(())
})?;
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
let m = idx.len();
let landed = active_at.or(first_at);
s.remap_appearance_item(&ids, |a| {
Some(match to {
Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 },
None => a + idx.iter().filter(|i| **i < a).count(),
})
});
ok()
}
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = require_targets(s, p, "appearance.showAllHidden")?;
let ids = s.edit("Show All Hidden Attributes", |d, _| {
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
if shown.is_empty() {
return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into()));
}
Ok(shown)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// `appearance.moveItem`: moves fill/stroke `from` (or the Contents row) to `to`, or the rows `indices` as one block; the active item stays on its row.
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let to = index_param(p, "to", C)?;
let ids = require_targets(s, p, C)?;
let aligned = aligned(p, C)?;
if p.get("from").and_then(Value::as_str) == Some("contents") {
return move_contents(s, &ids, to);
}
// Where an aligned row would go in each of several different stacks isn't one place.
if aligned && appearances_differ(&s.doc()?.doc, &ids) {
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
}
if p.get("indices").is_some() {
if p.get("from").is_some() {
return Err(bad(C, "give `from` or `indices`, not both"));
}
return move_items(s, p, &ids, to);
}
let from = index_param(p, "from", C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
let mut landed = to;
// When every object's stack comes out as it was, the document and its undo history stay as they
// are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
landed = n.appearance.move_item(from, to).ok_or_else(|| bad(C, format!("no item at index {from}")))?;
if let Some(k) = contents.filter(|_| n.contents_label().is_some()) {
n.appearance.set_contents_at(k);
}
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
Ok(count_changed(d, &before, &ids) + usize::from(kept))
})?;
// The moved row stays the active one; the rows it passed shift by one.
s.remap_appearance_item(&ids, |a| {
Some(if a == from {
landed
} else if from < a && a <= landed {
a - 1
} else if landed <= a && a < from {
a + 1
} else {
a
})
});
Ok(json!({ "index": landed }))
}
/// `appearance.moveItem {indices}`: move several items as one block (the lowest landing at `to`),
/// placing the Contents row with `contents` when given, as one undo step. The active row moves with
/// its row; the rows the block passes are renumbered.
fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<Value> {
const C: &str = "appearance.moveItem";
let idx = item_indices(p, C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
// Where the block landed on the first target (stacks of other lengths clamp `to` differently):
// the result, as the panel lists the first object's stack.
let mut first_landed = None;
// Where it landed on the active row's object: the active row is in its stack.
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut active_landed = None;
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
let landed = n.appearance.move_items(&idx, to).ok_or_else(|| bad(C, format!("no item at one of the indices {idx:?}")))?;
first_landed.get_or_insert(landed);
if active == Some(*id) {
active_landed = Some(landed);
}
if let Some(k) = contents.filter(|_| n.contents_label().is_some()) {
n.appearance.set_contents_at(k);
}
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, ids));
Ok(count_changed(d, &before, ids) + usize::from(kept))
})?;
let m = idx.len();
let landed = first_landed.unwrap_or(to);
let at = active_landed.unwrap_or(landed);
s.remap_appearance_item(ids, |a| {
Some(match idx.iter().position(|i| *i == a) {
Some(k) => at + k,
None => {
// Its place among the unmoved rows, then above the block if it is at or above it.
let r = a - idx.iter().filter(|i| **i < a).count();
if r < at { r } else { r + m }
}
})
});
Ok(json!({ "index": landed }))
}
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
/// has one above its bottom `to` items.
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
// When every Contents row is above the bottom `to` items already, the document and its undo
// history stay as they are.
let before = s.doc()?.doc.clone();
let mut slot = None;
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
for id in ids {
let Some(n) = d.node_mut(*id).filter(|n| n.contents_label().is_some()) else { continue };
n.appearance.set_contents_at(to);
slot.get_or_insert(n.appearance.contents_at());
}
slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?;
Ok(count_changed(d, &before, ids))
})?;
Ok(json!({ "contents": slot }))
}
fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
let roots = appearance_targets(s, p)?;
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = roots
.iter()
.filter_map(|id| d.node(*id))
.filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
.flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id))
.collect();
if ids.is_empty() {
return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members"));
}
s.select(|_, sel| sel.set(ids.iter().copied()))?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Sample enabled attributes, refusing object transparency that a text range cannot store.
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.copyFrom";
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
let defaults_only = switch_or(p, "defaultsOnly", false, C)?;
if defaults_only && (reverse || append || p.get("ids").is_some()) {
return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`"));
}
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() {
super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b)
} else {
None
};
let selected = if defaults_only {
vec![]
} else {
match ids_param(p, "ids") {
Some(v) => outermost(&s.doc()?.doc, v),
None => selected_roots(s)?,
}
};
// Reversed, the first selected object's attributes go onto the clicked object; with nothing
// selected, what the Eyedropper picked up last does (#4382).
let (source, mut targets) = if reverse {
let source = match leaf_targets(s, &selected)?.into_iter().next() {
Some(first) => Some(first),
None if s.eyedropper_sample.is_some() => None,
None => return Err(bad(C, "select the object to copy from")),
};
(source, leaf_targets(s, &[clicked])?)
} else {
(Some(clicked), leaf_targets(s, &selected)?)
};
targets.retain(|id| Some(*id) != source);
let node = match source {
Some(id) => Arc::new(s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.clone()),
None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?,
};
let picked = Picked::of(&node);
if !reverse && !defaults_only {
s.eyedropper_sample = Some(node);
}
// The highlighted characters' type objects and ranges: after Select All in threaded text, all
// the text of every frame of the story, except a frame the attributes come from, in place of
// the object selection.
let story = super::textedit::editing_story(s);
let ranges: Vec<(NodeId, Option<(usize, usize)>)> = match range {
Some((id, _, _)) if story.contains(&id) => {
targets.clear();
story.iter().copied().filter(|f| Some(*f) != source).map(|f| (f, None)).collect()
}
Some((id, start, end)) if targets.contains(&id) => vec![(id, Some((start, end)))],
_ => vec![],
};
// Character styles cannot represent object transparency. Refuse before even changing
// paint defaults; applying it to the object would also alter the unselected characters.
if attrs.appearance.transparency {
for (id, _) in &ranges {
let target = s.doc()?.doc.node(*id).ok_or(EngineError::NoNode(*id))?;
if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal {
return Err(bad(
C,
"object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object",
));
}
}
}
if !reverse {
picked.to_defaults(s, &attrs.appearance);
}
if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() {
return Ok(json!({ "ids": [] }));
}
if !ranges.is_empty() {
let ids: Vec<NodeId> = ranges.iter().map(|(f, _)| *f).collect();
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
for &(id, range) in &ranges {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let (start, end) = range.unwrap_or((0, vectorcraft_text::edit::runs_len(&t.runs)));
let placed = picked.placed(Some(t.local_bounds()));
vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs));
if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) {
t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone());
}
super::typecmd::refresh_bounds(t);
}
Ok(count_changed(d, &before, &ids))
})?;
return Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
}
// When every target comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Eyedropper", |d| {
for id in &targets {
if let Some(n) = d.node_mut(*id) {
picked.apply(n, &attrs, append);
}
}
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &targets));
Ok(count_changed(d, &before, &targets) + usize::from(kept))
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// What the Eyedropper picks up from an object.
struct Picked {
/// The appearance stack (type: its first run's fill and stroke as a basic appearance).
appearance: Appearance,
/// The box (in the source's paint space) its placed gradients are relative to.
bounds: Option<Rect>,
opacity: f32,
blend: BlendMode,
/// Type's first character style and its paragraph attributes.
text: Option<(CharStyle, ParaStyle)>,
}
impl Picked {
fn of(n: &Node) -> Self {
let (appearance, bounds, text) = match &n.kind {
NodeKind::Text(t) if !t.runs.is_empty() => {
let st = &t.runs[0].style;
(st.basic_appearance(), Some(t.local_bounds()), Some((st.clone(), t.para.clone())))
}
_ => ((*n.appearance).clone(), n.geometric_bounds(), None),
};
Self { appearance, bounds, opacity: n.opacity, blend: n.blend, text }
}
/// The appearance placed on an object whose box (in its paint space) is `to`: placed gradients
/// land at the same place relative to it.
fn placed(&self, to: Option<Rect>) -> Appearance {
let mut ap = self.appearance.clone();
if let (Some(f), Some(t)) = (self.bounds, to) {
ap.rebase_gradients(f, t);
}
ap
}
/// The paint defaults for new art take the picked-up fill, stroke and weight.
fn to_defaults(&self, s: &mut Session, a: &AppearanceAttrs) {
if a.fill.color {
s.paint.fill = super::gradient::unplaced(&self.appearance.fill_paint());
s.remember_paint(&s.paint.fill.clone());
}
if a.stroke.color {
s.paint.stroke = super::gradient::unplaced(&self.appearance.stroke_paint());
}
if a.stroke.weight && self.appearance.stroke().is_some() {
s.paint.stroke_width = self.appearance.stroke_width();
}
}
/// Copy character attributes and the enabled paint attributes through the same path for
/// whole text objects and highlighted character ranges.
fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) {
if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) {
*st = CharStyle {
fill: std::mem::take(&mut st.fill),
stroke: std::mem::take(&mut st.stroke),
stroke_width: st.stroke_width,
overprint_fill: st.overprint_fill,
overprint_stroke: st.overprint_stroke,
..cs.clone()
};
}
run_attrs(st, placed, &a.appearance);
}
/// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its
/// stack instead. Type takes the paints on its characters, and character and paragraph
/// attributes from type.
fn apply(&self, n: &mut Node, a: &EyedropperAttrs, append: bool) {
let ap = &a.appearance;
if ap.transparency {
n.opacity = self.opacity;
n.blend = self.blend;
}
if append {
let placed = self.placed(n.geometric_bounds());
n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color }));
n.appearance.effects.extend(placed.effects);
return;
}
if let NodeKind::Text(t) = &mut n.kind {
let placed = self.placed(Some(t.local_bounds()));
let text = self.text.as_ref();
for r in &mut t.runs {
self.run(&mut r.style, &placed, a);
}
if let Some((_, para)) = text.filter(|_| a.paragraph) {
// The source's first paragraph's attributes, on every paragraph.
t.set_all_paras(para.clone());
}
if text.is_some() && (a.character || a.paragraph) {
super::typecmd::refresh_bounds(t);
}
return;
}
let placed = self.placed(n.geometric_bounds());
if ap.fill == FillAttrs::ALL && ap.stroke == StrokeAttrs::ALL {
n.appearance = placed.into();
} else {
focal_attrs(&mut n.appearance, &placed, ap);
}
}
}
/// The focal (topmost) fill and stroke attributes `a` of `from` onto `to`. Taking a colour creates
/// a missing fill or stroke.
fn focal_attrs(to: &mut Appearance, from: &Appearance, a: &AppearanceAttrs) {
if a.fill.color {
to.set_fill(from.fill_paint());
}
if let (Some(t), Some(f)) = (to.fill_mut(), from.fill()) {
if a.fill.transparency {
(t.opacity, t.blend) = (f.opacity, f.blend);
}
if a.fill.overprint {
t.overprint = f.overprint;
}
}
if a.stroke.color {
to.set_stroke(from.stroke_paint());
}
if let (Some(t), Some(k)) = (to.stroke_mut(), from.stroke()) {
let s = &a.stroke;
if s.transparency {
(t.opacity, t.blend) = (k.opacity, k.blend);
}
if s.overprint {
t.overprint = k.overprint;
}
if s.weight {
t.width = k.width;
}
if s.cap {
t.cap = k.cap;
}
if s.join {
t.join = k.join;
}
if s.miter {
t.miter_limit = k.miter_limit;
}
if s.dash {
t.dash = k.dash.clone();
}
}
}
/// The fill and stroke attributes `a` of `from` onto a type run's characters (their stroke takes
/// what characters have: paint, weight, cap, join, miter limit, dashes and overprint). Characters
/// without a painted stroke get no weight, a painted one at least 1 pt.
fn run_attrs(st: &mut CharStyle, from: &Appearance, a: &AppearanceAttrs) {
let mut ap = st.basic_appearance();
focal_attrs(&mut ap, from, a);
let (Some(f), Some(k)) = (ap.fill(), ap.stroke()) else { return };
(st.fill, st.overprint_fill, st.overprint_stroke) = (f.paint.clone(), f.overprint, k.overprint);
let width = if a.stroke.color { ap.stroke_width() } else { k.width };
st.set_stroke_layer(&StrokeLayer { width, ..k.clone() });
if !st.stroke.is_none() {
super::gradient::run_stroke_weight(st);
}
}