mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 02:30:09 +00:00
Merge pull request #5728 from mockydevs/fix/expand-transform-groups-5612
Effects: Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612)
This commit is contained in:
@@ -79,8 +79,8 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
|
||||
pub use reshape::{
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint,
|
||||
needs_outline, outline_art, outline_text, reshape,
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options,
|
||||
image_paint, needs_outline, outline_art, outline_text, reshape,
|
||||
};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
|
||||
@@ -39,8 +39,13 @@ pub fn needs_outline(n: &Node) -> bool {
|
||||
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
|
||||
/// paints its gradient by itself ([`copy_leaf`]), as a group's copies do.
|
||||
pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
(n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) && has_transform_copies(n)
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path with a visible Transform effect that makes copies? Expand
|
||||
/// Appearance makes each copy an object of its own ([`expand_outlined`]).
|
||||
pub fn has_transform_copies(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
|
||||
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
|
||||
&& geometry_effects(n)
|
||||
.iter()
|
||||
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
|
||||
@@ -249,11 +254,20 @@ fn with_id(art: &Node, id: NodeId) -> Node {
|
||||
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
|
||||
/// `doc`'s symbols that its instances show toward its instance budget.
|
||||
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
|
||||
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
|
||||
}
|
||||
|
||||
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
|
||||
fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
|
||||
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]); with
|
||||
/// `apart`, also a path or compound path whose Transform effect makes copies
|
||||
/// ([`has_transform_copies`]), as the one piece the copies are made from.
|
||||
fn outline_art_with(
|
||||
doc: Option<&Document>,
|
||||
n: &Node,
|
||||
symbol_art: Option<&Node>,
|
||||
outline: OutlineHook,
|
||||
nodes: &SymbolNodes,
|
||||
apart: bool,
|
||||
) -> Option<Node> {
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Text(_) => outline_text(n)?,
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
@@ -268,7 +282,7 @@ fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>,
|
||||
Node::group(n.id, vec![Arc::new(a)])
|
||||
}
|
||||
// The object as the one piece the copies of its Transform effect are made from.
|
||||
_ if has_transform_art(n) => {
|
||||
_ if has_transform_art(n) || (apart && has_transform_copies(n)) => {
|
||||
let mut one = n.clone();
|
||||
one.opacity = 1.0;
|
||||
one.blend = Default::default();
|
||||
@@ -429,10 +443,12 @@ pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> O
|
||||
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
|
||||
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
|
||||
pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
|
||||
// patterns: each copy an object of its own.
|
||||
if has_transform_art(n) {
|
||||
return as_art(doc, n, symbol_art);
|
||||
// A path whose Transform effect copies it, or scales its strokes or moves its patterns: each
|
||||
// copy an object of its own with the path's fills and strokes, the copies of each Transform
|
||||
// effect in a group of their own (copies of copies nest), as the reference app expands them
|
||||
// (#5612).
|
||||
if has_transform_art(n) || has_transform_copies(n) {
|
||||
return as_art_opts(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
|
||||
}
|
||||
let own_paint = match n.kind {
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty(),
|
||||
@@ -451,6 +467,12 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>
|
||||
|
||||
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
|
||||
pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
|
||||
as_art_opts(doc, n, symbol_art, outline, nodes, false)
|
||||
}
|
||||
|
||||
/// [`as_art_with`]; with `apart`, a path's or compound path's Transform effects make each copy an
|
||||
/// object of its own ([`copy_leaf`]) rather than one path of every copy.
|
||||
fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes, apart: bool) -> Option<Node> {
|
||||
let fx = geometry_effects(n);
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
@@ -478,9 +500,12 @@ pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&
|
||||
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
|
||||
}
|
||||
}
|
||||
_ => outline_art_with(doc, n, symbol_art, outline, nodes)?,
|
||||
_ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?,
|
||||
};
|
||||
reshape_leaves(&mut art, &fx);
|
||||
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart);
|
||||
if apart {
|
||||
hoist_copies(&mut art);
|
||||
}
|
||||
transform_items(&mut art);
|
||||
// Type and symbols in perspective: the art projected onto their plane.
|
||||
if let Some(h) = n.projection() {
|
||||
@@ -546,6 +571,28 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
|
||||
/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost.
|
||||
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) {
|
||||
reshape_leaves_apart(art, fx, budget, false);
|
||||
}
|
||||
|
||||
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
|
||||
/// the group of its copies ([`copy_leaf`]) as its only member: the copies become its members, so
|
||||
/// the expanded path is one group of its copies rather than a group in a group.
|
||||
fn hoist_copies(art: &mut Node) {
|
||||
let id = art.id;
|
||||
let Some(children) = art.children_mut() else { return };
|
||||
let [only] = children.as_slice() else { return };
|
||||
if only.id != id || !matches!(only.kind, NodeKind::Group { clip: false, .. }) {
|
||||
return;
|
||||
}
|
||||
let copies = only.children().cloned().unwrap_or_default();
|
||||
if !copies.is_empty() {
|
||||
*children = copies;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
|
||||
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them.
|
||||
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) {
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -588,14 +635,14 @@ pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budg
|
||||
if let Some((m, copies, options, map)) = transform {
|
||||
// Transform Patterns and Scale Strokes & Effects change each copy's fills and
|
||||
// strokes; a gradient paints each copy by itself, not the one piece the copies
|
||||
// make together.
|
||||
let gradient = copies > 0 && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient());
|
||||
// make together; and Expand Appearance (`apart`) makes each copy an object.
|
||||
let separate = copies > 0 && (apart || leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient());
|
||||
if options.reach_appearance() {
|
||||
let made = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
used = used.minus(before).plus(made);
|
||||
return;
|
||||
}
|
||||
if gradient {
|
||||
if separate {
|
||||
let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room);
|
||||
used = used.minus(before).plus(made);
|
||||
return;
|
||||
|
||||
@@ -758,11 +758,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art);
|
||||
m
|
||||
})
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_transform_art(&v) {
|
||||
} else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& !effects::has_transform_art(&v)
|
||||
&& !effects::has_transform_copies(&v)
|
||||
{
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else {
|
||||
// Type, images, symbol instances, live objects (and paths whose Transform effect copies
|
||||
// them with a gradient or changes their fills and strokes): through their outlines.
|
||||
// them, each copy an object of its own, or changes their fills and strokes): through
|
||||
// their outlines.
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n))
|
||||
|
||||
@@ -366,14 +366,16 @@ fn expand_appearance_bakes_geometry() {
|
||||
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Each copy is an object of its own (#5612).
|
||||
#[test]
|
||||
fn expand_transform_copies_makes_compound() {
|
||||
fn expand_transform_copies_makes_a_group_of_the_copies() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Compound { .. }));
|
||||
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label());
|
||||
assert_eq!(n.children().map(|c| c.len()), Some(2));
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
@@ -474,6 +476,53 @@ fn expand_appearance_gives_each_copy_of_a_transform_its_own_gradient() {
|
||||
assert_eq!(boxes, vec![100.0; 3], "three squares, each with its own gradient");
|
||||
}
|
||||
|
||||
/// Expand Appearance on a solid-filled path whose Transform effect makes copies gives a group of
|
||||
/// one path per copy, each with the path's fill, not one compound path (#5612); a second Transform
|
||||
/// effect's copies of those copies nest, a group per copy of the first.
|
||||
#[test]
|
||||
fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
let fill = |n: &vectorcraft_doc::Node| n.appearance.fill_paint().color();
|
||||
let (mut s, id) = session_with_rect();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"params":{"moveH":150,"copies":2},"ids":[id.0],"item":null})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Group { clip: false, .. }), "{}", n.kind_label());
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
let copies = n.children().unwrap();
|
||||
assert_eq!(copies.len(), 3, "the path and its two copies");
|
||||
let xs: Vec<f64> = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect();
|
||||
assert_eq!(xs, vec![100.0, 250.0, 400.0]);
|
||||
for c in copies {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label());
|
||||
assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
assert!((c.geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
|
||||
}
|
||||
let mut ids: Vec<NodeId> = vec![];
|
||||
n.walk(&mut |c| ids.push(c.id));
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 4, "the group and each copy have ids of their own");
|
||||
// Two Transform effects: three columns, each a group of the column's two copies.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc().unwrap().doc, *before);
|
||||
s.execute("effect.apply", &json!({"effect":"distort.transform","ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":1,"params":{"moveV":150,"copies":1},"ids":[id.0],"item":null})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let columns = node(&s, id).children().unwrap().to_vec();
|
||||
assert_eq!(columns.len(), 3);
|
||||
for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) {
|
||||
let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label()));
|
||||
let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect();
|
||||
assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]);
|
||||
assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
/// The object's own effect ids, in order.
|
||||
fn object_effects(s: &Session, id: NodeId) -> Vec<String> {
|
||||
node(s, id).appearance.effects.iter().map(|e| e.id.clone()).collect()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); previously: Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
|
||||
|---|---|---:|---|
|
||||
| Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — |
|
||||
| Crop Marks | Crop Marks | 1/1 | — |
|
||||
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) |
|
||||
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646); Expand Appearance of a path whose Transform effect makes copies gives a group of one object per copy, each painted as the path, and a second Transform effect's copies nest in a group per copy of the first (#5612) |
|
||||
| Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) |
|
||||
| Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own |
|
||||
| Rasterize | Rasterize… (as a live effect) | 1/1 | — |
|
||||
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. |
|
||||
| 2026-10-30 | minor | Pixelate › Color Halftone: Max Radius is in pixels of the Document Raster Effects resolution, 4–127 px, as the [filter effects reference](https://helpx.adobe.com/photoshop/using/filter-effects-reference.html) gives it, instead of points (#3260): fine screens are possible at print resolutions (4 px at 300 ppi ≈ 0.96 pt). The dialog and the Appearance panel show it in px whatever the document units; documents saved before (document versions 1 and 2) open with each radius converted, so their dots stay the same size, and a file saved for an older app version (native versions before today's) holds them in points again. Scores unchanged. Tests: `vectorcraft_effects` `color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution` and `color_halftones_radius_is_in_raster_pixels_not_a_distance`, `vectorcraft_doc` `halftone_radii::tests`, `vectorcraft_format` `halftone_radii_of_older_documents_open_in_pixels`, `vectorcraft_engine` `a_preserve_editing_pdf_opens_with_its_halftone_radius_in_pixels`, `dialogs::tests::color_halftones_max_radius_stays_in_pixels_whatever_the_units`. |
|
||||
| 2026-10-30 | minor | Effect dialogs: bounded length fields (Color Halftone Max Radius) clamp on commit and visibly reject invalid input (#4638); scores unchanged. |
|
||||
| 2026-10-23 | minor | Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)): the Pathfinder panel's Trap… (`object.pathfinder.trap`, one undo step, the traps grouped above the topmost selected object and selected) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add an overprinting strip of the lighter colour inside the darker one where two solid colours meet (Reverse Traps: the darker into the lighter, but not a rich black), with Thickness, Height/Width, Tint Reduction and Traps with Process Color; the effect also has Precision and Remove Redundant Points. Recounted: Hard Mix (#3061) and Soft Mix had shipped, so Pathfinder 11/13 → 13/13 and Illustrator effects 46/50 → 49/50 (SVG Filters remains); remaining 6–10 → 4–8 h. Tests: `vectorcraft_pathops` `trap::tests`, `vectorcraft_effects` `trap::tests` and `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `vectorcraft_engine` `tests_trap`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. |
|
||||
|
||||
Reference in New Issue
Block a user