Merge pull request #4458 from dotCooCoo/fix/pattern-and-mask-editing-exclusive

Transparency: pattern editing mode and opacity mask editing can't be entered from each other
This commit is contained in:
BFlat
2026-10-29 20:18:08 +07:00
committed by GitHub
37 changed files with 380 additions and 33 deletions
+29 -14
View File
@@ -6,8 +6,8 @@
//! effect, as in the reference app). Leaving the mode removes the layer, and saving or exporting
//! never writes it ([`Document::without_edit_modes`]). Meanwhile the Transparency panel's commands
//! act on the masked object (see `opacitymask::transparency_targets`). Mask editing is not entered
//! in isolation mode ([`outside_isolation_mode`]), and isolation mode is not entered while a mask is
//! edited ([`outside_mask_editing`]).
//! in isolation mode or pattern editing mode ([`can_edit_mask`]), and neither mode is entered while
//! a mask is edited ([`outside_mask_editing`]).
use std::sync::Arc;
@@ -29,8 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Opacity Mask",
["Window", "Transparency"],
None,
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode → {layer}",
outside_isolation_mode,
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}",
can_edit_mask,
enter
),
cmd!(
@@ -47,8 +47,8 @@ pub fn specs() -> Vec<CommandSpec> {
"View Opacity Mask",
["Window", "Transparency"],
None,
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode → {on, id}",
outside_isolation_mode,
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode and in pattern editing mode → {on, id}",
can_edit_mask,
view_mask
),
]
@@ -60,15 +60,27 @@ pub(crate) fn in_isolation_mode(st: &crate::DocState) -> bool {
st.isolation.is_some() && st.doc.pattern_edit.is_none() && st.doc.mask_edit.is_none()
}
/// A document is open and it is not in isolation mode ([`in_isolation_mode`]): opacity mask
/// editing is not entered in isolation mode.
pub(crate) fn outside_isolation_mode(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if in_isolation_mode(st) { Err("exit isolation mode first".into()) } else { Ok(()) }
/// Why opacity mask editing is not entered now, if it is not: in isolation mode
/// ([`in_isolation_mode`]) and in pattern editing mode, whose tile layer the mask editing layer
/// would replace as the isolated layer.
pub(crate) fn mask_editing_refused(st: &crate::DocState) -> Option<&'static str> {
if st.doc.pattern_edit.is_some() {
Some("finish editing the pattern first")
} else if in_isolation_mode(st) {
Some("exit isolation mode first")
} else {
None
}
}
/// A document is open and no opacity mask is being edited: isolation mode is not entered while
/// an opacity mask is edited.
/// A document is open and opacity mask editing can be entered ([`mask_editing_refused`]).
pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into()))
}
/// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode
/// are not entered while an opacity mask is edited.
pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.doc.mask_edit.is_some() { Err("stop editing the opacity mask first".into()) } else { Ok(()) }
@@ -169,8 +181,11 @@ pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result
Ok(layer)
}
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
/// Leave mask editing in one undo step (`transparency.stopEditingOpacityMask`). `object.exitIsolation` runs it too
/// while a mask is edited.
pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> {
let Some(me) = s.doc()?.doc.mask_edit else { return Err(bad("transparency.stopEditingOpacityMask", "not editing an opacity mask")) };
s.end_text_edit();
s.edit("Stop Editing Opacity Mask", |d, sel| {
finish(d, sel);
Ok(())
+18 -1
View File
@@ -293,7 +293,24 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
pub(super) fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result<Value> {
// Opacity mask editing and pattern editing isolate their editing layer. Leaving isolation mode
// then leaves that mode, as Stop Editing Opacity Mask and the isolation bar's Done do.
let st = s.doc()?;
let leave_mode: Option<fn(&mut Session, &Value) -> Result<Value>> = if st.doc.mask_edit.is_some() {
Some(crate::cmd::maskedit::leave)
} else if st.doc.pattern_edit.is_some() {
Some(crate::cmd::patterncmds::pattern_done)
} else {
None
};
if let Some(leave_mode) = leave_mode {
if let Some(i) = st.isolation {
crate::cmd::distortcmds::finish_edit_text(s.doc_mut()?, i);
}
leave_mode(s, p)?;
return ok();
}
let menu = s.menu.clone();
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
+15 -11
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Opacity Mask",
["Window", "Transparency"],
None,
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode → {id, editing}",
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}",
can_make,
make
),
@@ -163,19 +163,20 @@ fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
}
/// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty
/// mask and takes into mask editing, is not in isolation mode
/// ([`super::maskedit::outside_isolation_mode`]). The selection is one object when it has one
/// subject root, which `make` masks without `ids`, and at most one outermost object, which it
/// masks with `ids` (a call's `ids` stand in for the selection here). `make` itself refuses any
/// single target in isolation mode, a layer included, so a selection with a target counts as one
/// object.
/// mask and takes into mask editing, is where mask editing can be entered
/// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The
/// selection is one object when it has one subject root, which `make` masks without `ids`, and at
/// most one outermost object, which it masks with `ids` (a call's `ids` stand in for the selection
/// here). A targeted layer is one subject root however many objects it selects. `make` itself
/// refuses any single target in those modes, a layer included, so a selection with a target counts
/// as one object.
fn can_make(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let Some(st) = s.active() else { return Ok(()) };
let roots = super::appearance::subject_roots(s).map_or(0, |r| r.len());
let outermost = super::edit::outermost(&st.doc, st.selection.in_paint_order(&st.doc)).len();
let targeted = st.selection.target.is_some();
if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::outside_isolation_mode(s) } else { Ok(()) }
if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::can_edit_mask(s) } else { Ok(()) }
}
fn make(s: &mut Session, p: &Value) -> Result<Value> {
@@ -194,9 +195,12 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
None => super::appearance::subject_roots(s)?,
};
let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) };
// One target gets an empty mask and enters mask editing, which isolation mode does not allow.
if below.is_empty() && super::maskedit::in_isolation_mode(s.doc()?) {
return Err(bad(C, "exit isolation mode first"));
// One target gets an empty mask and enters mask editing, which isolation mode and pattern
// editing mode do not allow.
if below.is_empty()
&& let Some(why) = super::maskedit::mask_editing_refused(s.doc()?)
{
return Err(bad(C, why));
}
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
+18 -4
View File
@@ -32,8 +32,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Make",
["Object", "Pattern"],
None,
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}",
has_selection,
"{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode; refused while an opacity mask is edited → {name}",
can_make_pattern,
pattern_make
),
cmd!(
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Pattern",
["Object", "Pattern"],
Some("Cmd+Shift+F8"),
"{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode",
"{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode; refused while an opacity mask is edited",
can_edit_pattern,
pattern_edit
),
@@ -161,11 +161,20 @@ fn in_pattern_edit(s: &Session) -> std::result::Result<(), String> {
editing(s).map(|_| ()).ok_or_else(|| "not in pattern editing mode".into())
}
/// Make: a selection, and no opacity mask is being edited
/// ([`super::maskedit::outside_mask_editing`]), since the tile layer would replace the mask editing
/// layer as the isolated layer.
fn can_make_pattern(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
super::maskedit::outside_mask_editing(s)
}
fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if editing(s).is_some() {
return Err("already editing a pattern".into());
}
super::maskedit::outside_mask_editing(s)?;
if s.active().is_some_and(|st| st.doc.patterns.is_empty()) { Err("the document has no patterns".into()) } else { Ok(()) }
}
@@ -355,7 +364,12 @@ fn pattern_name(p: &Paint) -> Option<String> {
}
}
fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
/// Save the edited tile art into the pattern and leave pattern editing mode (`object.pattern.done`).
/// `object.exitIsolation` runs it too while a pattern is edited.
pub(crate) fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
if editing(s).is_some() {
s.end_text_edit();
}
let name = s.edit("Done Editing Pattern", |d, sel| {
let (pe, art) = leave_edit(d, sel)?;
if let Some(def) = d.pattern_mut(&pe.pattern) {
+117
View File
@@ -6,6 +6,7 @@
use serde_json::{Value, json};
use vectorcraft_geom::Affine;
use vectorcraft_render::{RenderOptions, Renderer};
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::cmd::maskedit::MASK_EDIT_LAYER;
use crate::tests::{history, select_with_a_step_to_redo};
@@ -170,6 +171,52 @@ fn release_while_editing_leaves_editing_first() {
assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some());
}
/// Exit Isolation Mode while a mask is edited leaves mask editing as Stop Editing Opacity Mask
/// does: the mask keeps the edit, the editing layer is removed, nothing is isolated, the masked
/// object is selected on its layer, and one undo step returns to mask editing.
#[test]
fn exit_isolation_while_editing_leaves_mask_editing() {
let mut s = session();
let obj = editing_mask(&mut s);
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
s.execute("object.exitIsolation", &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none() && !has_edit_layer(&st.doc));
assert_eq!(st.selection.objects, vec![obj]);
assert_eq!(st.active_layer, Some(st.doc.layers[0].id));
assert_eq!(st.history.undo.last().unwrap().label, "Stop Editing Opacity Mask");
assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x0, 50.0);
s.execute("edit.undo", &json!({})).unwrap();
let st = s.doc().unwrap();
let layer = st.doc.mask_edit.map(|m| m.layer);
assert!(layer.is_some() && st.isolation == layer, "back in mask editing");
}
/// Point type that a Type tool click placed in the mask and left empty is discarded when Stop
/// Editing Opacity Mask or Exit Isolation Mode leaves mask editing, so the mask does not keep it.
#[test]
fn empty_point_type_is_not_saved_into_the_mask() {
for cmd in ["transparency.stopEditingOpacityMask", "object.exitIsolation"] {
let mut s = session();
s.prefs.placeholder_text = false;
let obj = editing_mask(&mut s);
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 250.0, 250.0), v).unwrap();
}
assert!(s.tool_wants_text(), "{cmd}: the click places point type to edit");
s.execute(cmd, &json!({})).unwrap();
assert!(!s.tool_wants_text(), "{cmd}");
let art = &mask(&s, obj).unwrap().art;
assert!(art.children().is_none() && !matches!(art.kind, vectorcraft_doc::NodeKind::Text(_)), "{cmd}: the mask keeps only the square");
assert_eq!(art.geometric_bounds().unwrap().x1, 100.0, "{cmd}");
let labels = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect::<Vec<_>>();
// The mask took the type in when it was placed, so its removal is a step of its own.
assert_eq!(labels[labels.len() - 3..], ["Type", "Discard Empty Type", "Stop Editing Opacity Mask"], "{cmd}");
}
}
/// Releasing the masks of a group and of an object inside it puts each mask's art directly above
/// its object, as releasing one mask at a time does.
#[test]
@@ -194,6 +241,76 @@ fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids()
assert!(*doc(&s) == before, "Undo gives back the document");
}
/// Opacity mask editing is not entered in pattern editing mode: Edit Opacity Mask and View Opacity
/// Mask are off for a masked object in the tile, and so is Make Opacity Mask for one object, which
/// would edit its new empty mask; a single target given in `ids` is refused. Make Opacity Mask with
/// two objects still masks one with the other. Pattern editing goes on, its layer still isolated.
/// After Done the mask is edited again.
#[test]
fn opacity_mask_editing_is_not_entered_in_pattern_editing_mode() {
let mut s = session();
let (obj, m) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0));
s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap();
s.execute("select.set", &json!({"ids": [obj.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
let tile = doc(&s).pattern_edit.as_ref().unwrap().layer;
let copy = doc(&s).node(tile).unwrap().children().unwrap()[0].id;
assert!(mask(&s, copy).is_some(), "the tile's copy has the mask");
let why = "finish editing the pattern first";
let off = |s: &mut Session, cmd: &str, p: Value| {
let err = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}");
};
off(&mut s, "transparency.editOpacityMask", json!({}));
off(&mut s, "transparency.editOpacityMask", json!({"id": copy.0}));
off(&mut s, "transparency.viewOpacityMask", json!({}));
off(&mut s, "transparency.viewOpacityMask", json!({"on": true, "id": copy.0}));
let one = rect(&mut s, 220.0, 30.0);
s.execute("select.set", &json!({"ids": [one.0]})).unwrap();
off(&mut s, "transparency.makeOpacityMask", json!({}));
off(&mut s, "transparency.makeOpacityMask", json!({"ids": [one.0]}));
// With two objects selected the command is on, and one target given in `ids` is refused.
s.execute("select.set", &json!({"ids": [copy.0, one.0]})).unwrap();
for target in [one, tile] {
let err = s.execute("transparency.makeOpacityMask", &json!({"ids": [target.0]})).unwrap_err();
assert!(err.to_string().ends_with(why), "{err}");
}
let st = s.doc().unwrap();
assert!(st.doc.mask_edit.is_none() && st.mask_view.is_none() && mask(&s, one).is_none());
assert_eq!((st.isolation, st.active_layer), (Some(tile), Some(tile)));
// Two objects: the top one masks the other, without mask editing.
let top = rect(&mut s, 220.0, 10.0);
s.execute("select.set", &json!({"ids": [one.0, top.0]})).unwrap();
let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
assert_eq!(r, json!({"id": one.0, "editing": false}));
assert_eq!(s.doc().unwrap().isolation, Some(tile));
assert!(doc(&s).pattern_edit.is_some());
s.execute("object.pattern.done", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap();
assert_eq!(doc(&s).mask_edit.map(|e| e.object), Some(obj));
}
/// In pattern editing mode a targeted sublayer holding two objects is one target, so Make Opacity
/// Mask is off for it.
#[test]
fn make_opacity_mask_is_off_for_a_targeted_sublayer_in_pattern_editing_mode() {
let mut s = session();
let obj = rect(&mut s, 0.0, 200.0);
s.execute("select.set", &json!({"ids": [obj.0]})).unwrap();
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
let tile = doc(&s).pattern_edit.as_ref().unwrap().layer;
let sub = s.execute("layer.newSublayer", &json!({"parent": tile.0})).unwrap()["id"].as_u64().unwrap();
let (a, b) = (rect(&mut s, 220.0, 30.0), rect(&mut s, 260.0, 30.0));
s.execute("layer.move", &json!({"ids": [a.0, b.0], "target": sub})).unwrap();
let kids = doc(&s).node(NodeId(sub)).unwrap().children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
assert_eq!(kids, vec![a, b], "the new objects are drawn in the sublayer");
s.execute("layer.target", &json!({"id": sub})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects.len(), 2, "the sublayer's two objects are selected");
let err = s.execute("transparency.makeOpacityMask", &json!({})).unwrap_err();
let why = "finish editing the pattern first";
assert!(matches!(&err, EngineError::Disabled(id, w) if id == "transparency.makeOpacityMask" && w == why), "{err}");
}
#[test]
fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
let mut s = session();
+79
View File
@@ -5,6 +5,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_tools::{PointerEvent, PointerKind};
use crate::tests_menucmds::place_undecodable_png;
use crate::*;
@@ -87,6 +88,52 @@ fn done_saves_edited_art_and_leaves_mode() {
assert!(s.doc().unwrap().isolation.is_none());
}
/// Exit Isolation Mode while a pattern is edited (the canvas context menu and the Layers panel
/// menu offer it) leaves pattern editing as Done does, with the drawn art saved into the pattern.
#[test]
fn exit_isolation_while_editing_a_pattern_saves_it_and_leaves_the_mode() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
rect(&mut s, 2.0, 2.0, 3.0, 3.0);
s.execute("object.exitIsolation", &json!({})).unwrap();
let d = doc(&s);
assert!(d.pattern_edit.is_none());
assert_eq!(d.pattern("P").unwrap().art.len(), 2);
assert!(d.layers.iter().all(|l| l.name.as_deref() != Some(PATTERN_EDIT_LAYER)));
let st = s.doc().unwrap();
assert_eq!((st.isolation, st.active_layer), (None, st.doc.default_layer()));
assert_eq!(st.history.undo.last().unwrap().label, "Done Editing Pattern");
}
/// Point type that a Type tool click placed in the tile and left empty is discarded when Done or
/// Exit Isolation Mode leaves pattern editing, so the pattern does not keep it.
#[test]
fn empty_point_type_is_not_saved_into_the_pattern() {
for cmd in ["object.pattern.done", "object.exitIsolation"] {
let mut s = session();
s.prefs.placeholder_text = false;
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
sel(&mut s, &[r]);
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
let v = ViewInfo::default();
s.select_tool("type", v).unwrap();
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, 200.0, 200.0), v).unwrap();
}
assert!(s.tool_wants_text(), "{cmd}: the click places point type to edit");
s.execute(cmd, &json!({})).unwrap();
assert!(!s.tool_wants_text(), "{cmd}");
let art = &doc(&s).pattern("P").unwrap().art;
assert_eq!(art.len(), 1, "{cmd}: the tile keeps only the square");
assert!(!matches!(art[0].kind, NodeKind::Text(_)), "{cmd}");
let labels = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect::<Vec<_>>();
assert_eq!(labels.last(), Some(&"Done Editing Pattern"), "{cmd}");
assert!(!labels.contains(&"Type"), "{cmd}: {labels:?}");
}
}
#[test]
fn cancel_new_pattern_removes_it() {
let mut s = session();
@@ -112,6 +159,38 @@ fn edit_then_cancel_restores_definition() {
assert_eq!(def.tile.width(), 20.0);
}
/// Pattern editing mode is not entered while an opacity mask is edited: Edit Pattern and Make are
/// off, with or without a name, and Make also with `edit: false`. Mask editing goes on, its layer
/// still isolated. After Stop Editing Opacity Mask, Edit Pattern works again.
#[test]
fn pattern_editing_is_not_entered_while_an_opacity_mask_is_edited() {
let mut s = session();
make_dots(&mut s);
let obj = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let m = rect(&mut s, 100.0, 100.0, 50.0, 100.0);
s.execute("paint.setFill", &json!({"ids": [m.0], "color": "#ffffff"})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap();
let layer = s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap()["layer"].as_u64().map(NodeId);
let why = "stop editing the opacity mask first";
for (cmd, p) in [
("object.pattern.edit", json!({"name": "Dots"})),
("object.pattern.edit", json!({})),
("object.pattern.make", json!({})),
("object.pattern.make", json!({"name": "Mask Art", "edit": false})),
] {
let err = s.execute(cmd, &p).unwrap_err();
assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}");
}
let st = s.doc().unwrap();
assert!(st.doc.pattern_edit.is_none() && st.doc.pattern("Mask Art").is_none() && st.doc.pattern("New Pattern 1").is_none());
assert_eq!(st.doc.mask_edit.map(|e| (e.object, Some(e.layer))), Some((obj, layer)));
assert_eq!((st.isolation, st.active_layer), (layer, layer));
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.isolation, st.doc.pattern_edit.as_ref().map(|e| e.layer));
}
#[test]
fn edit_defaults_to_selected_objects_pattern() {
let mut s = session();
+13
View File
@@ -703,6 +703,19 @@ impl Session {
}
}
/// End the Type tool's text editing before a command saves the art of an editing layer (pattern
/// editing's Done, Stop Editing Opacity Mask): point type its click placed that is still empty is
/// discarded first, so it is not saved into the pattern tile or the mask.
pub(crate) fn end_text_edit(&mut self) {
if self.in_tool_actions || !self.tool.wants_text() {
return;
}
let acts = self.with_tool_cx(self.last_view, |t, cx| t.deactivate(cx));
if let Err(e) = self.apply_actions(acts) {
log::warn!("ending the text edit: {e}");
}
}
/// Tell the active tool that `edit.undo` or `edit.redo` ran, on its own or as a step of
/// `command.batch`, so the Type tool puts the caret where the step changed the text; or that a
/// `command.batch` began, ended or was rolled back (`batch.begin`, `batch.end`,
+1
View File
@@ -4095,6 +4095,7 @@ axis Both И двете
@msg not an EMF or WMF file не е EMF или WMF файл
@msg not found не е открито
@msg not in pattern editing mode не е в режим на редактиране на мостра
@msg finish editing the pattern first първо завършете редактирането на мострата
@msg nothing encoded нищо не е кодирано
@msg nothing selected нищо не е избрано
@msg nothing selected is in a group нищо от избраното не е в група
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -5177,6 +5177,7 @@
@msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf
@msg not found no s’ha trobat
@msg not in pattern editing mode no esteu en mode d’edició de motius
@msg finish editing the pattern first acabeu primer l’edició del motiu
@msg nothing encoded no s’ha codificat res
@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for no s’ha aplicat res del conjunt de dades {name}: cap objecte està vinculat a una variable per a la qual tingui un valor
@msg nothing selected no hi ha res seleccionat
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4500,6 +4500,7 @@ imageTrace Outline Obrys
@msg not an EMF or WMF file nejde o soubor EMF ani WMF
@msg not found nenalezeno
@msg not in pattern editing mode není aktivní režim úprav vzorku
@msg finish editing the pattern first nejprve dokončete úpravy vzorku
@msg nothing encoded nic nebylo zakódováno
@msg nothing selected nic není vybráno
@msg nothing selected is in a group nic z výběru není ve skupině
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4200,6 +4200,7 @@ imageTrace Outline Kontur
@msg not an EMF or WMF file ikke en EMF- eller WMF-fil
@msg not found ikke fundet
@msg not in pattern editing mode ikke i mønsterredigeringstilstand
@msg finish editing the pattern first afslut først redigeringen af mønsteret
@msg nothing encoded intet kodet
@msg nothing selected intet markeret
@msg nothing selected is in a group intet af det markerede er i en gruppe
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4115,6 +4115,7 @@ theme Appearance Erscheinungsbild
@msg not an EMF or WMF file keine EMF- oder WMF-Datei
@msg not found nicht gefunden
@msg not in pattern editing mode nicht im Musterbearbeitungsmodus
@msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters
@msg nothing encoded nichts kodiert
@msg nothing selected nichts ausgewählt
@msg nothing selected is in a group nichts Ausgewähltes befindet sich in einer Gruppe
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4157,6 +4157,7 @@ theme Appearance Εμφάνιση
@msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF
@msg not found δεν βρέθηκε
@msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου
@msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου
@msg nothing encoded δεν κωδικοποιήθηκε τίποτα
@msg nothing selected δεν έχει επιλεγεί τίποτα
@msg nothing selected is in a group τίποτα από τα επιλεγμένα δεν βρίσκεται σε ομάδα
Can't render this file because it contains an unexpected character in line 2071 and column 19.
+1
View File
@@ -4194,6 +4194,7 @@ axis Both Ambos
@msg not an EMF or WMF file no es un archivo EMF ni WMF
@msg not found no se encontró
@msg not in pattern editing mode no está en el modo de edición de motivos
@msg finish editing the pattern first termina primero la edición del motivo
@msg nothing encoded no se codificó nada
@msg nothing selected no hay nada seleccionado
@msg nothing selected is in a group nada de lo seleccionado está en un grupo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4412,6 +4412,7 @@ imageTrace Outline Ääriviiva
@msg not an EMF or WMF file ei EMF- tai WMF-tiedosto
@msg not found ei löytynyt
@msg not in pattern editing mode ei kuvion muokkaustilassa
@msg finish editing the pattern first lopeta ensin kuvion muokkaus
@msg nothing encoded mitään ei koodattu
@msg nothing selected mitään ei ole valittu
@msg nothing selected is in a group mikään valituista ei ole ryhmässä
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4191,6 +4191,7 @@ axis Both Les deux
@msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF
@msg not found introuvable
@msg not in pattern editing mode pas en mode d’édition de motif
@msg finish editing the pattern first terminez d’abord la modification du motif
@msg nothing encoded rien n’a été encodé
@msg nothing selected aucune sélection
@msg nothing selected is in a group aucun élément sélectionné ne se trouve dans un groupe
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4867,6 +4867,7 @@ imageTrace Outline Körvonal
@msg not an EMF or WMF file nem EMF- vagy WMF-fájl
@msg not found nem található
@msg not in pattern editing mode nem mintázatszerkesztő módban vagy
@msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését
@msg nothing encoded nincs kódolt adat
@msg nothing selected nincs kijelölés
@msg nothing selected is in a group a kijelölésből semmi sincs csoportban
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4200,6 +4200,7 @@ imageTrace Outline Kontur
@msg not an EMF or WMF file bukan berkas EMF atau WMF
@msg not found tidak ditemukan
@msg not in pattern editing mode tidak dalam mode pengeditan pola
@msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu
@msg nothing encoded tidak ada yang dikodekan
@msg nothing selected tidak ada yang dipilih
@msg nothing selected is in a group tidak ada yang dipilih yang berada dalam grup
Can't render this file because it contains an unexpected character in line 3864 and column 19.
+1
View File
@@ -4234,6 +4234,7 @@ axis Both Entrambi
@msg not an EMF or WMF file non è un file EMF o WMF
@msg not found non trovato
@msg not in pattern editing mode non sei in modalità modifica motivo
@msg finish editing the pattern first termina prima la modifica del motivo
@msg nothing encoded nessun dato codificato
@msg nothing selected nessuna selezione
@msg nothing selected is in a group nessun elemento selezionato si trova in un gruppo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4147,6 +4147,7 @@ swatch Bright Violet ビビッドバイオレット
@msg already editing a pattern すでにパターンを編集中です
@msg no pattern `{name}` パターン `{name}` が見つかりません
@msg not in pattern editing mode パターン編集モードではありません
@msg finish editing the pattern first 先にパターンの編集を終了してください
@msg pattern missing パターンが見つかりません
@msg select a repeat リピートを選択してください
@msg select an Intertwine group 織り交ぜグループを選択してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3970,6 +3970,7 @@ font-technology Variable ცვლადი
@msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი
@msg not found ვერ მოიძებნა
@msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული
@msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება
@msg nothing encoded არაფერია კოდირებული
@msg nothing selected არაფერია მონიშნული
@msg nothing selected is in a group მონიშნულთაგან არაფერია ჯგუფში
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4079,6 +4079,7 @@ theme Appearance 외관
@msg already editing a pattern 이미 패턴을 편집 중입니다
@msg no pattern `{name}` `{name}` 패턴이 없습니다
@msg not in pattern editing mode 패턴 편집 모드가 아닙니다
@msg finish editing the pattern first 먼저 패턴 편집을 마치세요
@msg pattern missing 패턴이 없습니다
@msg select a repeat 반복을 선택하세요
@msg select an Intertwine group 교차 그룹을 선택하세요
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4212,6 +4212,7 @@ imageTrace Outline Contour
@msg not an EMF or WMF file geen EMF- of WMF-bestand
@msg not found niet gevonden
@msg not in pattern editing mode niet in patroonbewerkingsmodus
@msg finish editing the pattern first rond eerst het bewerken van het patroon af
@msg nothing encoded niets gecodeerd
@msg nothing selected niets geselecteerd
@msg nothing selected is in a group niets van de selectie zit in een groep
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4326,6 +4326,7 @@ imageTrace Outline Kontur
@msg not an EMF or WMF file to nie jest plik EMF ani WMF
@msg not found nie znaleziono
@msg not in pattern editing mode nie jest w trybie edycji wzorka
@msg finish editing the pattern first najpierw zakończ edycję wzorka
@msg nothing encoded nic nie zakodowano
@msg nothing selected nic nie zaznaczono
@msg nothing selected is in a group nic z zaznaczenia nie należy do grupy
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3770,6 +3770,7 @@ axis Both Ambos
@msg not an EMF or WMF file não é um arquivo EMF ou WMF
@msg not found não encontrado
@msg not in pattern editing mode não está no modo de edição de padrão
@msg finish editing the pattern first termine de editar o padrão primeiro
@msg nothing encoded nada codificado
@msg nothing selected nada selecionado
@msg nothing selected is in a group nada do que está selecionado está em um grupo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4230,6 +4230,7 @@ axis Both Оба
@msg not an EMF or WMF file не является файлом EMF или WMF
@msg not found не найдено
@msg not in pattern editing mode не в режиме редактирования узора
@msg finish editing the pattern first сначала завершите редактирование узора
@msg nothing encoded ничего не закодировано
@msg nothing selected ничего не выделено
@msg nothing selected is in a group ничего из выделенного не находится в группе
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4108,6 +4108,7 @@ axis Both Båda
@msg not an EMF or WMF file inte en EMF- eller WMF-fil
@msg not found hittades inte
@msg not in pattern editing mode inte i läget för mönsterredigering
@msg finish editing the pattern first slutför redigeringen av mönstret först
@msg nothing encoded inget kodat
@msg nothing selected inget markerat
@msg nothing selected is in a group inget av det markerade ligger i en grupp
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3965,6 +3965,7 @@ axis Both Her İkisi
@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor
@msg not found bulunamadı
@msg not in pattern editing mode desen düzenleme modunda değil
@msg finish editing the pattern first önce deseni düzenlemeyi bitir
@msg nothing encoded kodlanmış hiçbir şey yok
@msg nothing selected hiçbir şey seçilmedi
@msg nothing selected is in a group seçilenlerin hiçbiri bir grupta değil
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4218,6 +4218,7 @@ axis Both Обидві
@msg not an EMF or WMF file не є файлом EMF або WMF
@msg not found не знайдено
@msg not in pattern editing mode не в режимі редагування візерунка
@msg finish editing the pattern first спочатку завершіть редагування візерунка
@msg nothing encoded нічого не закодовано
@msg nothing selected нічого не виділено
@msg nothing selected is in a group нічого з виділеного не перебуває в групі
Can't render this file because it contains an unexpected character in line 749 and column 40.
+1
View File
@@ -4518,6 +4518,7 @@ theme Appearance Giao diện
@msg not an EMF or WMF file không phải tệp EMF hoặc WMF
@msg not found không tìm thấy
@msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết
@msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước
@msg nothing encoded không có gì được mã hóa
@msg nothing selected chưa chọn gì
@msg nothing selected is in a group không mục nào được chọn nằm trong nhóm
Can't render this file because it contains an unexpected character in line 4084 and column 10.
+1
View File
@@ -3880,6 +3880,7 @@ imageTrace Outline 轮廓
@msg already editing a pattern 已在编辑图案
@msg no pattern `{name}` 没有图案 `{name}`
@msg not in pattern editing mode 不在图案编辑模式中
@msg finish editing the pattern first 请先完成图案编辑
@msg pattern missing 缺少图案
@msg select a repeat 请选择一个重复对象
@msg select an Intertwine group 请选择一个交织组
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1834,6 +1834,7 @@ file-filter Templates 範本
@msg already editing a pattern 已在編輯圖樣
@msg no pattern `{name}` 沒有圖樣 `{name}`
@msg not in pattern editing mode 不在圖樣編輯模式中
@msg finish editing the pattern first 請先完成圖樣編輯
@msg pattern missing 找不到圖樣
@msg select a repeat 請選取重複物件
@msg select an Intertwine group 請選取交織群組
Can't render this file because it contains an unexpected character in line 6 and column 47.
@@ -23,6 +23,39 @@ fn click_mask(ctx: &egui::Context, app: &mut VectorcraftApp, modifiers: egui::Mo
frame(ctx, app, vec![], egui::Modifiers::NONE);
}
/// In pattern editing mode a click or an Alt-click on the mask thumbnail of a masked object in the
/// tile does not edit the mask, and the status bar says to finish editing the pattern first. Make
/// Mask is off for one object, which it would take into mask editing. After Done a click edits the
/// mask.
#[test]
fn the_mask_thumbnail_does_not_edit_the_mask_in_pattern_editing_mode() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap();
let b = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].clone();
run(&mut app, "select.set", json!({"ids": [a, b]}));
run(&mut app, "transparency.makeOpacityMask", json!({}));
run(&mut app, "select.set", json!({"ids": [a]}));
run(&mut app, "object.pattern.make", json!({"name": "P"}));
let tile = app.session.active().unwrap().isolation;
let ctx = egui::Context::default();
for modifiers in [egui::Modifiers::NONE, egui::Modifiers::ALT] {
app.ui.status.clear();
click_mask(&ctx, &mut app, modifiers);
let st = app.session.active().unwrap();
assert!(st.doc.mask_edit.is_none() && st.shown_mask().is_none(), "{modifiers:?}");
assert!(st.doc.pattern_edit.is_some() && st.isolation == tile, "{modifiers:?}");
assert!(app.ui.status.ends_with("finish editing the pattern first"), "{modifiers:?}: {}", app.ui.status);
}
run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 30, "height": 30}));
assert!(!crate::menus::enabled(&app, "transparency.makeOpacityMask"), "Make Mask is off for one object");
run(&mut app, "object.pattern.done", json!({}));
run(&mut app, "select.set", json!({"ids": [a]}));
click_mask(&ctx, &mut app, egui::Modifiers::NONE);
assert_eq!(app.session.active().unwrap().doc.mask_edit.map(|e| e.object.0), Some(a));
}
#[test]
fn alt_clicking_the_mask_thumbnail_views_only_the_mask() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+1 -1
View File
@@ -143,7 +143,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.vertical(|ui| {
let label = if mask.is_some() { tl!("Release") } else { tl!("Make Mask") };
// Make Mask is off in isolation mode for one object, which it would take into mask editing.
// Make Mask is off in isolation mode and pattern editing mode for one object, which it would take into mask editing.
let enabled = mask.is_some() || (has && editing.is_none() && crate::menus::enabled(app, "transparency.makeOpacityMask"));
let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner;
if r.on_disabled_hover_text(tl!("Select the art (and, on top of it, the mask object)")).clicked() {
+19
View File
@@ -726,6 +726,25 @@ fn a_command_on_cmd_escape_runs_without_the_tools_escape() {
assert_eq!(click_at(&mut app, 350.0), objects + 1, "nothing bound: Cmd+Escape ends the path as Escape does");
}
/// Escape while an opacity mask is edited leaves mask editing, as Stop Editing Opacity Mask does:
/// the editing layer is removed, nothing is isolated, and the masked object is selected.
#[test]
fn escape_while_an_opacity_mask_is_edited_leaves_mask_editing() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let obj = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
let m = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 100})).unwrap()["id"].clone();
app.session.execute("select.set", &json!({"ids": [obj, m]})).unwrap();
app.session.execute("transparency.makeOpacityMask", &json!({})).unwrap();
app.session.execute("transparency.editOpacityMask", &json!({})).unwrap();
app.select_tool("selection");
frame(&mut app, press(Key::Escape, Modifiers::NONE));
let st = app.session.active().unwrap();
assert!(st.doc.mask_edit.is_none() && st.isolation.is_none(), "Escape leaves mask editing");
assert_eq!(st.doc.layers.len(), 1, "the editing layer is removed");
assert_eq!(st.selection.objects, vec![vectorcraft_doc::NodeId(obj)]);
}
/// Enter opens the tool's dialog: the Move dialog for the selection tools, the tool's own for
/// Rotate, Scale, Reflect and Shear. The Enter that opens it isn't also its OK.
#[test]
+11 -2
View File
@@ -1241,7 +1241,9 @@ Transparency and opacity-mask commands take `ids` (or `id`), so they need no sel
(0..100) in every command (`transparency.set`, `object.setProps`, `appearance.setItem`). `transparency.info` returns
the Transparency panel's values, with `null` where the objects differ. Making a mask from one object gives it an
empty mask and enters mask editing: art drawn then becomes the mask, and the mask commands act on the masked object
until `transparency.stopEditingOpacityMask`. Saves and exports never include the editing layer.
until `transparency.stopEditingOpacityMask`. While a mask is edited, `object.exitIsolation` (Esc, the isolation bar's
back arrow, Exit Isolation Mode) leaves mask editing the same way, in one undo step. Saves and exports never include
the editing layer.
```json
{"name":"run_command","arguments":{"command":"transparency.set","params":{"ids":[12],"opacity":40,"blend":"Multiply"}}}
@@ -2118,7 +2120,8 @@ couldn't be decoded and is missing from the picture", the note raster exports gi
In pattern editing mode the tile edge (`pattern.options {showTileEdge}`) and the swatch bounds (`pattern.options
{showSwatchBounds}`: the part of the tiling the swatch repeats, dashed) are drawn in the preference
`patternTileEdgeColor`.
`patternTileEdgeColor`. There `object.exitIsolation` (the Exit Isolation Mode item of the canvas context menu and of the
Layers panel menu) saves the tile art into the pattern and leaves the mode, as `object.pattern.done` does.
`object.pattern.make {name?}`, `object.pattern.saveCopy {name?}` and `pattern.options {newName?}` trim the white space
around the name they are given. The first two number a name that a pattern, a swatch or a color group has ("Dots 2"),
@@ -2133,6 +2136,12 @@ old name keeps it. A blank name gives "New Pattern N" in `object.pattern.make` a
{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}}
```
Pattern editing mode and opacity mask editing exclude each other. In pattern editing mode
`transparency.editOpacityMask` and `transparency.viewOpacityMask` are off, and `transparency.makeOpacityMask` refuses
one target, an object or a layer, which it would take into mask editing; a call returns an error that ends "finish
editing the pattern first". While a mask is edited, `object.pattern.edit` and `object.pattern.make` (also with
`edit: false`) are off, and a call returns an error that ends "stop editing the opacity mask first".
Pathfinder Options: `object.pathfinder.options {precision?, removeRedundantPoints?, removeUnpainted?}` (or
`prefs.get`/`prefs.set` with key `pathfinder`) reads and sets the options every later Shape Mode and Pathfinder
operation uses: `precision` (0.001–10 pt, default 0.01; a string with a unit also reads) is how closely results follow
+2
View File
@@ -606,6 +606,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401).
- The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box.
- In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected.
- While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does.
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
- Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column.
- The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown.
@@ -832,6 +833,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498).
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286).
Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group.
Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off.
Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set.
- **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged.
- **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.