diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bd0e7704f..f4b2d21d2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", + "{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset) ), @@ -962,19 +962,28 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine { /// Insert clipboard resources atomically in the active editable context using the requested placement mode. fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result { let off = s.prefs.paste_offset; + let boards_at = match p.get("artboardsAt") { + Some(v) if mode == PasteMode::Offset => Some( + point_param(p, "artboardsAt") + .filter(|c| v.as_array().is_some_and(|a| a.len() == 2) && c.x.is_finite() && c.y.is_finite()) + .ok_or_else(|| bad("edit.paste", "`artboardsAt` must be [x, y]"))?, + ), + _ => None, + }; let st = s.doc()?; let same_doc = clip.source_doc == Some(st.uid); let parent = st.target_parent_for_path()?; // Copied artboards come back with their art: together right of the last artboard, as they lay, - // or where they were (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the - // art. - let boards = clip - .artboards - .iter() - .map(|a| a.rect) - .reduce(|a, b| a.union(b)) - .filter(|_| mode != PasteMode::AllArtboards) - .map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO })); + // or centered on `artboardsAt`, or where they were (Paste in Place, in Front, in Back). Paste on + // All Artboards pastes only the art. + let boards = clip.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).filter(|_| mode != PasteMode::AllArtboards).map(|all| { + let dv = match (mode, boards_at) { + (PasteMode::Offset, Some(c)) => c - all.center(), + (PasteMode::Offset, None) => super::panelcmds::beside_artboards(&st.doc, all), + _ => Vec2::ZERO, + }; + (&clip.artboards, dv) + }); // Drawing modes (#3705): Draw Inside puts what Paste, Paste in Place and Paste on All // Artboards bring inside the object it draws into; Draw Behind puts what Paste in Place and // Paste on All Artboards bring behind the selection, as Paste in Back does. Paste in Front and diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 97293f36c..11bf22110 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -164,7 +164,7 @@ pub fn specs() -> Vec { "Fit to Artwork Bounds", ["Object", "Artboards"], None, - "{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art", + "{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step (none when every artboard already fits): an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art", has_doc, artboard_fit_art ), @@ -1151,6 +1151,11 @@ fn artboard_fit_art(s: &mut Session, p: &Value) -> Result { if fits.is_empty() { return Err(EngineError::Other("no artwork".into())); } + // An artboard that already fits its art changes nothing, and no undo step is recorded for it. + fits.retain(|(_, was, now)| [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6)); + if fits.is_empty() { + return ok(); + } s.edit("Artboard Options", |d, _| { for &(i, was, now) in &fits { let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?; diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index ea81bf26e..2e3ae4301 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Duplicate Artboard", ["Window", "Artboards"], None, - "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", + "{index?: n | indices?: [n…] (default: the artboards the Artboard tool has selected), selectedArtboards?: [n…] (the journal records that default here, for a replay), art?: bool (default: the Artboard tool's moveArt option), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} a copy of each artboard, the copies placed together right of the last artboard as they lay, with copies of the art fully inside each when `art` (locked and hidden art only with prefs moveLockedWithArtboard), in one undo step → {index: the first copy's index, indices: the copies' indices, ids: the art's copies}", has_doc, artboard_duplicate ), @@ -25,18 +25,42 @@ pub fn specs() -> Vec { "Copy Artboard", [], None, - "{index? | indices?, art?} (defaults as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, selectedArtboards?, art?} (as artboard.duplicate) put the artboards, and the art fully inside each when `art`, on the clipboard: edit.paste adds a copy of each, together right of the last artboard as they lay, edit.pasteInPlace/InFront/InBack where they were, in this or another document (one undo step; edit.pasteOnAllArtboards pastes only the art); edit.copy runs this while the Artboard tool is chosen → {copied: objects}", has_doc, - |s, p| artboard_copy(s, p, false) + |s, p| { + boards_and_art_noted(s, p, "artboard.copy")?; + artboard_copy(s, p, false) + } ), cmd!( "artboard.cut", "Cut Artboard", [], None, - "{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, selectedArtboards?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", has_doc, - |s, p| artboard_copy(s, p, true) + |s, p| { + boards_and_art_noted(s, p, "artboard.cut")?; + artboard_copy(s, p, true) + } + ), + cmd!( + "artboard.rename", + "Rename Artboards", + [], + None, + "{index? | indices?, selectedArtboards?: [n…] (as artboard.duplicate), name} (default: the artboards the Artboard tool has selected, else its active one) rename the artboards in one undo step: one takes `name` (trimmed), and several take it numbered in artboard order, `name-1`, `name-2` and so on; a locked artboard is renamed too. An empty name is an error, and when every artboard already has its new name nothing changes and no undo step is recorded → {renamed: [index…]}", + has_doc, + artboard_rename + ), + cmd!( + "artboard.switchOrientation", + "Switch Orientation", + [], + None, + "{index? | indices?, selectedArtboards?: [n…] (as artboard.duplicate), scaleArt?: bool (default: the Artboard tool's Scale Artwork with Artboard option), patterns?, lockedAndHidden?: bool (as artboard.setProps)} (default: the artboards the Artboard tool has selected, else its active one) each artboard swaps its width and height and keeps its top-left corner, in one undo step; a square artboard and a locked one stay as they are, the call fails with `the artboard is locked` when every artboard it names is locked, and when nothing else changes no undo step is recorded → {switched: [index…]}", + has_doc, + artboard_switch_orientation ), cmd!( "text.setFormat", @@ -82,24 +106,120 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result { /// of them is in the document) and its Move/Copy Artwork with Artboard option. fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { check_switches(p, &["art"], cmd)?; + let indices = named_or_selected(s, p, cmd)?; + Ok((indices, bool_or(p, "art", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true)))) +} + +/// [`boards_and_art`], with both values added to the running command's journal entry, so a replay +/// acts on the same artboards and art whatever the Artboard tool's state is then. `edit.cut` and +/// `edit.copy` call [`artboard_copy`] without this and take no such params. +fn boards_and_art_noted(s: &mut Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { + let (indices, art) = boards_and_art(s, p, cmd)?; + note_artboards(s, p, &indices); + s.note_journal("art", json!(art)); + Ok((indices, art)) +} + +/// Add `selectedArtboards` to the running command's journal entry when `p` names no artboard, so a +/// replay acts on the artboards the Artboard tool had selected. An action leaves it out +/// (`REPLAY_ONLY`) and keeps an `index` or `indices` the call gave. +fn note_artboards(s: &mut Session, p: &Value, indices: &[usize]) { + if p.get("index").is_none() && p.get("indices").is_none() { + s.note_journal("selectedArtboards", json!(indices)); + } +} + +/// The artboards `cmd` acts on: `index` or `indices`, else `selectedArtboards` (a replay of the +/// journal), else those the Artboard tool has selected, else its active one. +fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result> { let t = s.tool_options_of("artboard"); let d = &s.doc()?.doc; - let indices = if p.get("index").is_some() || p.get("indices").is_some() { - artboard_indices(d, p, cmd)? - } else { - let mut picked: Vec = t["selected"] - .as_array() - .into_iter() - .flatten() - .filter_map(Value::as_u64) - .filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len())) - .collect(); - if picked.is_empty() { - picked.push(t["active"].as_u64().unwrap_or(0)); - } - artboard_indices(d, &json!({ "indices": picked }), cmd)? + if p.get("index").is_some() || p.get("indices").is_some() { + return artboard_indices(d, p, cmd); + } + if let Some(recorded) = p.get("selectedArtboards") { + return artboard_indices(d, &json!({ "indices": recorded }), cmd); + } + let mut picked: Vec = t["selected"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len())) + .collect(); + if picked.is_empty() { + picked.push(t["active"].as_u64().unwrap_or(0)); + } + artboard_indices(d, &json!({ "indices": picked }), cmd) +} + +/// Rename Artboards: one artboard takes the name, and several take it numbered in artboard order, in +/// one undo step. Nothing runs when each already has its new name. +fn artboard_rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "artboard.rename"; + let name = match p.get("name") { + Some(Value::String(n)) if !n.trim().is_empty() => n.trim(), + Some(Value::String(_)) => return Err(bad(C, "`name` must not be empty")), + _ => return Err(bad(C, "`name` must be a string")), }; - Ok((indices, bool_or(p, "art", t["moveArt"].as_bool().unwrap_or(true)))) + let indices = named_or_selected(s, p, C)?; + note_artboards(s, p, &indices); + let d = &s.doc()?.doc; + let several = indices.len() > 1; + let entries: Vec<(usize, String)> = indices + .iter() + .enumerate() + .map(|(k, i)| (*i, if several { format!("{name}-{}", k + 1) } else { name.to_string() })) + .filter(|(i, new)| d.artboards.get(*i).is_some_and(|a| a.name != *new)) + .collect(); + let renamed: Vec = entries.iter().map(|(i, _)| *i).collect(); + if !entries.is_empty() { + let list: Vec = entries.iter().map(|(i, new)| json!({"index": i, "name": new})).collect(); + s.execute("artboard.setProps", &json!({ "artboards": list }))?; + } + Ok(json!({ "renamed": renamed })) +} + +/// Switch Orientation: each artboard that is neither square nor locked swaps its width and height +/// and keeps its top-left corner, in one undo step. Nothing runs when none of them changes. +fn artboard_switch_orientation(s: &mut Session, p: &Value) -> Result { + const C: &str = "artboard.switchOrientation"; + // Swapping width and height keeps the area, so `artboard.setProps` scales no strokes or corners. + const FROM_PREFS: [&str; 2] = ["patterns", "lockedAndHidden"]; + check_switches(p, &["scaleArt"], C)?; + check_switches(p, &FROM_PREFS, C)?; + let indices = named_or_selected(s, p, C)?; + note_artboards(s, p, &indices); + let d = &s.doc()?.doc; + if indices.iter().all(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) { + refuse_locked(d, &indices)?; + } + let turned: Vec<(usize, Rect)> = indices + .iter() + .filter_map(|i| d.artboards.get(*i).map(|a| (*i, a))) + .filter(|(_, a)| !a.locked && (a.rect.width() - a.rect.height()).abs() > 1e-6) + .map(|(i, a)| (i, a.rect)) + .collect(); + let switched: Vec = turned.iter().map(|(i, _)| *i).collect(); + if !turned.is_empty() { + let list: Vec = turned.iter().map(|(i, r)| json!({"index": i, "width": r.height(), "height": r.width()})).collect(); + let scale = bool_or(p, "scaleArt", s.tool_options_of("artboard")["scaleArt"].as_bool().unwrap_or(false)); + s.note_journal("scaleArt", json!(scale)); + let mut set = json!({ "artboards": list, "scaleArt": scale }); + for key in FROM_PREFS { + if let Some(v) = p.get(key) { + set[key] = v.clone(); + } + } + // What the step takes from the preferences joins this command's journal entry. + let (_, noted) = s.execute_step("artboard.setProps", &set)?; + for key in FROM_PREFS { + if let Some(v) = noted.get(key) { + s.note_journal(key, v.clone()); + } + } + } + Ok(json!({ "switched": switched })) } /// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of @@ -170,7 +290,7 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec /// their places relative to each other, together right of the last artboard. fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { check_switches(p, &["patterns"], "artboard.duplicate")?; - let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?; + let (indices, art) = boards_and_art_noted(s, p, "artboard.duplicate")?; let arts = artboards_art(s, &indices, art, false)?; let all: Vec = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() }; diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 4a628d6ef..4752b0a96 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -24,9 +24,10 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo /// `(command, param)` pairs the journal records only so a replay reproduces the original run /// (dates from the clock: `cmd::clock_date`; the artboards the app passes to the -/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the view's center -/// the app passes to Edit Symbol); an action leaves them out ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 16] = [ +/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the +/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol); +/// an action leaves them out ([`Session::journal_for_action`]). +const REPLAY_ONLY: [(&str, &str); 21] = [ ("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified"), @@ -39,6 +40,11 @@ const REPLAY_ONLY: [(&str, &str); 16] = [ ("artboard.fitToArt", "index"), ("artboard.fitToArt", "indices"), ("artboard.fitToSelection", "index"), + ("artboard.duplicate", "selectedArtboards"), + ("artboard.copy", "selectedArtboards"), + ("artboard.cut", "selectedArtboards"), + ("artboard.rename", "selectedArtboards"), + ("artboard.switchOrientation", "selectedArtboards"), ("select.allOnArtboard", "artboard"), ("symbol.edit", "artboard"), ("symbol.edit", "viewCenter"), diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 92feb107f..26fcc0835 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -247,6 +247,149 @@ fn a_batch_step_records_what_it_took_from_the_preferences() { assert_eq!(doc(&r), doc(&s)); } +/// A document of three artboards with a rectangle on the second, the Artboard tool chosen and that +/// artboard selected → the journal's length, where the entries under test start. +fn second_artboard_selected(s: &mut Session) -> usize { + s.execute("file.new", &json!({"width": 400, "height": 200, "created": null})).unwrap(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 300, "height": 100})).unwrap(); + s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 100, "height": 300})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([1])); + s.journal.len() +} + +/// A new session's Artboard tool has nothing selected, so a replay of a command that took its +/// artboards from the selection acts on the artboards it recorded, and an action does not keep them. +#[test] +fn artboard_commands_record_the_selected_artboards_for_a_replay_and_not_for_an_action() { + for (id, params, recorded, action) in [ + ("artboard.switchOrientation", json!({}), json!({"selectedArtboards": [1], "scaleArt": false}), json!({"scaleArt": false})), + ("artboard.rename", json!({"name": "Back"}), json!({"name": "Back", "selectedArtboards": [1]}), json!({"name": "Back"})), + ("artboard.duplicate", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), + ("artboard.cut", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), + ("artboard.copy", json!({}), json!({"selectedArtboards": [1], "art": true}), json!({"art": true})), + ] { + let mut s = Session::new(); + let start = second_artboard_selected(&mut s); + s.execute(id, ¶ms).unwrap(); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), recorded), "{id}"); + assert_eq!(s.journal_for_action(start), [(id.to_string(), action)], "{id}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); + } +} + +/// A copy of the artboard the tool had selected replays as a copy of that artboard: what it pastes +/// is the same. +#[test] +fn a_replayed_artboard_copy_pastes_the_selected_artboard() { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute("artboard.copy", &json!({})).unwrap(); + s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 4); + assert_eq!(documents(&replayed(&s)), documents(&s)); +} + +/// The artboards a call names are recorded as given and stay in an action, and a batch step records +/// the selected ones in its own params. +#[test] +fn artboard_commands_keep_a_named_artboard_and_note_a_batch_steps_artboards() { + let mut s = Session::new(); + let start = second_artboard_selected(&mut s); + s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap(); + assert_eq!(s.journal.last().unwrap().1, json!({"index": 0, "scaleArt": false})); + s.execute("artboard.duplicate", &json!({"indices": [0]})).unwrap(); + assert_eq!(s.journal.last().unwrap().1, json!({"indices": [0], "art": true})); + let steps = json!([{"command": "artboard.switchOrientation", "params": {}}]); + s.execute("command.batch", &json!({ "commands": steps })).unwrap(); + assert_eq!(s.journal.last().unwrap().1["commands"][0]["params"], json!({"selectedArtboards": [1], "scaleArt": false})); + assert_eq!(documents(&replayed(&s)), documents(&s)); + let action = s.journal_for_action(start); + assert_eq!(action[0].1, json!({"index": 0, "scaleArt": false})); + assert_eq!(action[1].1, json!({"indices": [0], "art": true})); + assert_eq!(action[2].1["commands"][0]["params"], json!({"scaleArt": false})); +} + +/// A recorded `selectedArtboards` goes ahead of the tool's selection, gives way to a named artboard, +/// and is checked as `indices` is. +#[test] +fn artboard_commands_read_the_recorded_selected_artboards() { + let mut s = Session::new(); + second_artboard_selected(&mut s); + let run = |s: &mut Session, p| s.execute("artboard.switchOrientation", &p).map(|r| r["switched"].clone()); + assert_eq!(run(&mut s, json!({"selectedArtboards": [2]})).unwrap(), json!([2])); + assert_eq!(run(&mut s, json!({"selectedArtboards": [2], "index": 0})).unwrap(), json!([0])); + for bad in [json!([9]), json!([]), json!("1"), json!(null)] { + assert!(run(&mut s, json!({ "selectedArtboards": bad })).is_err(), "{bad}"); + } +} + +/// `edit.copy` and `edit.cut` run the Artboard tool's copy and take no artboards, so their entries +/// stay as given. +#[test] +fn edit_copy_and_cut_with_the_artboard_tool_record_their_params_as_given() { + for id in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.execute(id, &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap(), &(id.to_string(), json!({})), "{id}"); + } +} + +/// `artboard.duplicate` and the cut and copy record the tool's Move/Copy Artwork option: replayed in +/// a new session, where the option is on, the art stays behind as it did. +#[test] +fn artboard_commands_record_the_tools_move_art_option() { + for id in ["artboard.duplicate", "artboard.cut", "artboard.copy"] { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.set_tool_option("moveArt", &json!(false)); + s.execute(id, &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap().1["art"], json!(false), "{id}"); + assert_eq!(documents(&replayed(&s)), documents(&s), "{id}"); + } +} + +/// `artboard.switchOrientation` records the tool's Scale Artwork option: replayed in a new session, +/// where it is off, the art scales as it did. +#[test] +fn artboard_switch_orientation_records_the_tools_scale_art_option() { + let mut s = Session::new(); + second_artboard_selected(&mut s); + s.set_tool_option("scaleArt", &json!(true)); + s.execute("artboard.switchOrientation", &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap().1["scaleArt"], json!(true)); + assert_eq!(documents(&replayed(&s)), documents(&s)); +} + +/// `artboard.switchOrientation` records whether locked and hidden art goes along: replayed in a new +/// session, where the preference is off, the locked art scales as it did. +#[test] +fn artboard_switch_orientation_records_whether_locked_art_goes_along() { + let mut s = Session::new(); + second_artboard_selected(&mut s); + let r = s.execute("shape.rectangle", &json!({"x": 1150, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("layer.setProps", &json!({"ids": [r["id"]], "locked": true})).unwrap(); + s.prefs.move_locked_with_artboard = true; + s.set_tool_option("scaleArt", &json!(true)); + s.execute("artboard.switchOrientation", &json!({})).unwrap(); + assert_eq!(s.journal.last().unwrap().1["lockedAndHidden"], json!(true)); + let r = replayed(&s); + assert!(!r.prefs.move_locked_with_artboard); + assert_eq!(documents(&r), documents(&s)); + // The params' own values win over the preferences. + let mut t = Session::new(); + second_artboard_selected(&mut t); + t.prefs.move_locked_with_artboard = true; + t.execute("artboard.switchOrientation", &json!({"scaleArt": true, "lockedAndHidden": false})).unwrap(); + assert_eq!(t.journal.last().unwrap().1["lockedAndHidden"], json!(false)); + assert!(t.execute("artboard.switchOrientation", &json!({"strokes": "yes"})).is_err()); +} + /// Every open document of `s`, as JSON. fn documents(s: &Session) -> Vec { s.documents().iter().map(|d| serde_json::to_value(&*d.doc).unwrap()).collect() diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 3cc47b7ac..706f40940 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -241,6 +241,150 @@ fn artboard_reorder_past_u32_goes_last() { assert_eq!(s.doc().unwrap().doc.artboards.last().map(|a| a.name.as_str()), Some("Second")); } +/// `artboard.rename`: one artboard takes the name trimmed, several take it numbered in artboard +/// order in one undo step, and a locked artboard is renamed too. +#[test] +fn artboard_rename_numbers_several_in_one_undo_step() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 2, "locked": true})).unwrap(); + let names = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect::>(); + let steps = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("artboard.rename", &json!({"index": 0, "name": " Cover "})).unwrap()["renamed"], json!([0])); + assert_eq!(names(&s)[0], "Cover"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + // Listed out of order and twice: artboard order, once each. + assert_eq!(s.execute("artboard.rename", &json!({"indices": [2, 1, 2], "name": "Page"})).unwrap()["renamed"], json!([1, 2])); + assert_eq!(names(&s)[1..], ["Page-1", "Page-2"]); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 2, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_ne!(names(&s)[1], "Page-1", "one undo restores both"); + assert_ne!(names(&s)[2], "Page-2"); +} + +/// `artboard.rename` with nothing to change records no undo step, and without `index` or `indices` +/// acts on the artboards the Artboard tool has selected. +#[test] +fn artboard_rename_records_nothing_when_nothing_changes() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.rename", &json!({"indices": [0, 1], "name": "Page"})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("artboard.rename", &json!({"indices": [0, 1], "name": "Page"})).unwrap()["renamed"], json!([])); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + s.set_tool_option("selected", &json!([1])); + assert_eq!(s.execute("artboard.rename", &json!({"name": "Back"})).unwrap()["renamed"], json!([1])); + assert_eq!(s.doc().unwrap().doc.artboards[1].name, "Back"); +} + +/// `artboard.rename` refuses an empty or missing name, a name that is not a string and an unknown +/// artboard, and leaves the document and its undo history as they were. +#[test] +fn artboard_rename_refuses_bad_params() { + let mut s = session(); + let steps = s.doc().unwrap().history.undo.len(); + for p in [ + json!({"index": 0, "name": " "}), + json!({"index": 0}), + json!({"index": 0, "name": 7}), + json!({"index": 9, "name": "A"}), + json!({"indices": [], "name": "A"}), + json!({"indices": [0, 9], "name": "A"}), + json!({"index": "0", "name": "A"}), + ] { + assert!(s.execute("artboard.rename", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Artboard 1"); +} + +/// `artboard.switchOrientation` swaps width and height of each artboard named in one undo step, +/// each top-left corner staying put; a square one and a locked one stay as they are. +#[test] +fn artboard_switch_orientation_swaps_each_in_one_undo_step() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 10, "width": 300, "height": 100})).unwrap(); + s.execute("artboard.new", &json!({"x": 2000, "y": 20, "width": 50, "height": 50})).unwrap(); + s.execute("artboard.new", &json!({"x": 3000, "y": 30, "width": 40, "height": 60})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 3, "locked": true})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + let r = s.execute("artboard.switchOrientation", &json!({"indices": [0, 1, 2, 3]})).unwrap(); + assert_eq!(r["switched"], json!([0, 1])); + let rects: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height())).collect(); + assert_eq!(rects, [(0.0, 0.0, 600.0, 800.0), (1000.0, 10.0, 100.0, 300.0), (2000.0, 20.0, 50.0, 50.0), (3000.0, 30.0, 40.0, 60.0)]); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[1].rect.width(), 300.0); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 800.0, "one undo restores both"); +} + +/// `artboard.switchOrientation`: a square artboard records no undo step, every artboard locked is an +/// error, and bad params change nothing. +#[test] +fn artboard_switch_orientation_refuses_and_records_nothing() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 50, "height": 50})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("artboard.switchOrientation", &json!({"index": 1})).unwrap()["switched"], json!([])); + assert_eq!(s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap_err().to_string(), "the artboard is locked"); + for p in [json!({"index": 9}), json!({"indices": []}), json!({"index": "1"}), json!({"index": 1, "scaleArt": "yes"})] { + assert!(s.execute("artboard.switchOrientation", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps); +} + +/// `artboard.switchOrientation` takes the art along with `scaleArt`, and by default follows the +/// Artboard tool's Scale Artwork with Artboard option. +#[test] +fn artboard_switch_orientation_scales_art_with_the_tools_option() { + let mut s = session(); + let id = rect(&mut s); + let width = |s: &Session| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap().width(); + let was = width(&s); + s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap(); + assert_eq!(width(&s), was, "the art stays by default"); + s.select_tool("artboard", crate::tooling::ViewInfo::default()).unwrap(); + s.set_tool_option("scaleArt", &json!(true)); + s.execute("artboard.switchOrientation", &json!({"index": 0})).unwrap(); + assert_ne!(width(&s), was, "the tool's option scales the art"); +} + +/// `artboard.fitToArt` records no undo step when every artboard already fits its art. +#[test] +fn artboard_fit_to_art_records_nothing_when_it_already_fits() { + let mut s = session(); + rect(&mut s); + s.execute("artboard.fitToArt", &json!({"index": 0})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + let was = s.doc().unwrap().doc.artboards[0].rect; + s.execute("artboard.fitToArt", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect, was); +} + +/// `edit.paste {artboardsAt}` centers the copied artboards on the point as they lay. A value that is +/// not a point is an error. +#[test] +fn paste_centers_copied_artboards_on_artboards_at() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap(); + s.execute("artboard.copy", &json!({"indices": [0, 1]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + for bad in [json!("here"), json!([1]), json!(["1", "2"]), json!([1, 2, 3]), json!(null)] { + assert!(s.execute("edit.paste", &json!({"artboardsAt": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + let r = s.execute("edit.paste", &json!({"artboardsAt": [5000.0, 7000.0]})).unwrap(); + assert_eq!(r["artboards"], json!([2, 3])); + let d = &s.doc().unwrap().doc; + let together = d.artboards[2].rect.union(d.artboards[3].rect).center(); + assert_eq!((together.x, together.y), (5000.0, 7000.0)); + assert_eq!(d.artboards[3].rect.x0 - d.artboards[2].rect.x0, 1000.0, "as they lay"); +} + /// The artboards' left edges and names. fn boards(s: &Session) -> Vec<(f64, String)> { s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect() diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index d8ef1bff8..bcd7c578f 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -202,7 +202,7 @@ impl VectorcraftApp { { self.last_pathfinder = Some(op.to_string()); } - if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { + if r.is_ok() && matches!(id, "edit.copy" | "edit.cut" | "artboard.copy" | "artboard.cut") { self.publish_clipboard(); } self.sync_views(); diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index 38311b773..5af09ab3b 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -6,7 +6,8 @@ use super::{Ui, VectorcraftApp, Xf, json, widgets}; /// objects under the pointer ([`vectorcraft_doc::hit::locked_objects_at`]) are kept in /// `ui.context_locked` while the menu is open, for its Unlock submenu. With the Intertwine tool, a /// right-click where three or more objects of the group overlap keeps the selection and puts what -/// it arranges in `ui.context_intertwine` ([`vectorcraft_tools::intertwine::menu_target`]). +/// it arranges in `ui.context_intertwine` ([`vectorcraft_tools::intertwine::menu_target`]). With the +/// Artboard tool, an artboard takes the place of the object ([`crate::panels::artboards::right_click`]). pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) { if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() @@ -19,7 +20,12 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: .active() .filter(|_| app.session.tool_id() == "intertwine") .and_then(|st| vectorcraft_tools::intertwine::menu_target(&st.doc, &st.selection.objects, p).map(|(g, m)| (st.uid, g, p, m))); - if app.ui.context_intertwine.is_none() + let on_artboards = app.session.tool_id() == "artboard"; + if on_artboards { + crate::panels::artboards::right_click(app, p); + } + if !on_artboards + && app.ui.context_intertwine.is_none() && let Some(st) = app.session.active() && let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation)) && !st.selection.contains(top) @@ -32,6 +38,7 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: if resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked)).is_none() { app.ui.context_locked = None; app.ui.context_intertwine = None; + app.ui.context_artboards = None; } if let Some((id, p)) = clicked { crate::menus::invoke(app, &id, p); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c0f900f67..5944a7a66 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -91,6 +91,7 @@ pub mod raster_effects; pub mod rearrange_artboards; pub mod recolor; mod recovery; +pub mod rename_artboards; mod revolve; pub(crate) mod revolve_gizmo; pub mod saturate; @@ -415,6 +416,7 @@ registry! { NavigatorPanelOptions: [navigator_panel_options::KIND] => navigator_panel_options::SPEC, PathfinderOptions: [pathfinder_options::KIND] => pathfinder_options::SPEC, RearrangeArtboards: [rearrange_artboards::KIND] => rearrange_artboards::SPEC, + RenameArtboards: [rename_artboards::KIND] => rename_artboards::SPEC, PathToolOptions: [crate::addons::pathtools::KIND] => crate::addons::pathtools::OPTIONS_SPEC, DivideDashes: [crate::addons::pathtools::dashes::KIND] => crate::addons::pathtools::dashes::SPEC, SetCorners: [crate::addons::pathtools::set_corners::KIND] => crate::addons::pathtools::set_corners::SPEC, diff --git a/crates/ui-egui/src/dialogs/rename_artboards.rs b/crates/ui-egui/src/dialogs/rename_artboards.rs new file mode 100644 index 000000000..82761e928 --- /dev/null +++ b/crates/ui-egui/src/dialogs/rename_artboards.rs @@ -0,0 +1,104 @@ +//! The Artboard tool's canvas menu › Rename: a name for the selected artboards. OK runs +//! `artboard.rename` as one undo step; with several artboards the name is numbered. +//! +//! Fields: `name` (the artboard's name with one artboard, empty with several) and `indices` (the +//! artboards the dialog opened on). + +use serde_json::{Value, json}; + +use super::{DialogResult, DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the artboard Rename dialog. +pub const KIND: &str = "renameArtboards"; + +const CMD: &str = "artboard.rename"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Rename").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; + +/// The fields the menu item opens with, for the selected artboards. +pub fn open_fields(app: &VectorcraftApp) -> Value { + let Some(st) = app.session.active() else { return json!({"name": "", "indices": []}) }; + let indices = crate::panels::artboards::picked(app, st.doc.artboards.len()); + let name = match indices.as_slice() { + [one] => st.doc.artboards.get(*one).map_or("", |a| a.name.as_str()), + _ => "", + }; + json!({ "name": name, "indices": indices }) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::label_row(ui, tl!("Name:"), 56.0, |ui| { + let r = form::text_edit_named(ui, d, "name", tl!("Name:"), 220.0); + if ui.memory(|m| m.focused().is_none()) { + r.request_focus(); + } + }); + false +} + +/// An empty name keeps the names as they are, as the Artboards panel's name field does. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult { + let name = d.str("name"); + if name.trim().is_empty() { + app.ui.dialog = None; + return Ok(Value::Null); + } + let indices = d.fields.get("indices").cloned().unwrap_or(Value::Null); + run_and_close(app, CMD, json!({ "indices": indices, "name": name })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 50, "artboards": 3})).unwrap(); + app.select_tool("artboard"); + app + } + + fn names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect() + } + + /// With one artboard the field starts on its name; with several it starts empty, and OK numbers + /// the name in one undo step. + #[test] + fn the_menu_item_renames_the_selected_artboards() { + let mut app = app(); + crate::menus::invoke(&mut app, CMD, json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, KIND); + assert_eq!(d.str("name"), "Artboard 1"); + app.ui.dialog = None; + + app.session.set_tool_option("selected", &json!([2, 0])); + crate::menus::invoke(&mut app, CMD, json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), ""); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(" Page ")); + let steps = app.session.active().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(names(&app), ["Page-1", "Artboard 2", "Page-2"]); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps + 1, "one undo step"); + } + + /// OK with an empty name or the name the artboard already has records no undo step. + #[test] + fn nothing_changes_for_an_empty_or_unchanged_name() { + let mut app = app(); + let steps = app.session.active().unwrap().history.undo.len(); + for name in [" ", "Artboard 1"] { + crate::menus::invoke(&mut app, CMD, json!({})); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(name)); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + } + assert_eq!(names(&app), ["Artboard 1", "Artboard 2", "Artboard 3"]); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e60b76cef..fce973e68 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -872,6 +872,7 @@ font-technology Variable Променливи Duplicate Artboard Дублиране на работна област Copy Artboard Копиране на работна област Cut Artboard Изрязване на работна област + Rename Artboards Преименуване на работни области Duplicate Artboards Дублиране на работни области Duplicate Brush Дублиране на четка Duplicate Character Style Дублиране на стил на знаци @@ -2503,6 +2504,9 @@ digits Malayalam Малаялам Real World: Реален свят: Real-time Drawing and Editing Чертане и редактиране в реално време Rearrange All Artboards Пренареждане на всички работни области + Rearrange Пренареждане + Select All Artboards Избиране на всички работни области + Switch Orientation Смяна на ориентацията Rearrange All Пренареждане на всички Layout Order: Ред на оформление: Change to Left-to-Right Layout Превключване към подредба отляво надясно diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0d95e8018..16f779bed 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1924,6 +1924,9 @@ digits Malayalam Malajalam More Paragraph options Više opcija pasusa No document open Nijedan dokument nije otvoren Quick Actions Brze akcije + Rearrange Preuredi + Select All Artboards Odaberi sve radne ploče + Switch Orientation Promijeni orijentaciju Rearrange All Preuredi sve Rulers & Grids Ravnala i mreže Select a text object Odaberite tekstualni objekat @@ -3446,6 +3449,7 @@ theme Appearance Izgled Character / Paragraph Znak / pasus Copy Artboard Kopiraj radnu ploču Cut Artboard Izreži radnu ploču + Rename Artboards Preimenuj radne ploče Move Artboard Up/Down Pomakni radnu ploču gore/dolje Add Anchor (Pen) Dodaj sidro (pero) Add Anchor Point Dodaj sidrenu tačku diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index a3cb5b42a..491f45913 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -962,6 +962,7 @@ Customize… Personalitza… Cut Retalla Cut Artboard Retalla el tauler + Rename Artboards Canvia el nom dels taulers Cut Path Retalla el traçat Cut Path at Selected Anchor Points Retalla el traçat als punts d’ancoratge seleccionats Cutout Retall @@ -3206,6 +3207,9 @@ digits Malayalam Malaiàlam Real name Nom real Real World: Món real: Real-time Drawing and Editing Dibuix i edició en temps real + Rearrange Reorganitza + Select All Artboards Selecciona tots els taulers + Switch Orientation Canvia l’orientació Rearrange All Reorganitza-ho tot Rearrange All Artboards Reorganitza tots els taulers Rearrange All Artboards… Reorganitza tots els taulers… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 063ddd591..82675a485 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1950,6 +1950,9 @@ digits Malayalam Malajálamské More Paragraph options Další možnosti odstavce No document open Není otevřen žádný dokument Quick Actions Rychlé akce + Rearrange Přeuspořádat + Select All Artboards Vybrat všechny pracovní plochy + Switch Orientation Přepnout orientaci Rearrange All Přeuspořádat vše Rulers & Grids Pravítka a mřížky Select a text object Vyberte textový objekt @@ -3485,6 +3488,7 @@ theme Appearance Motiv Character / Paragraph Znak / Odstavec Copy Artboard Kopírovat pracovní plochu Cut Artboard Vyjmout pracovní plochu + Rename Artboards Přejmenovat pracovní plochy Move Artboard Up/Down Posunout pracovní plochu nahoru/dolů Add Anchor (Pen) Přidat kotevní bod (pero) Add Anchor Point Přidat kotevní bod diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 9db09ea0f..0567c08dc 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1756,6 +1756,9 @@ digits Malayalam Malayalam More Paragraph options Flere afsnitsindstillinger No document open Intet åbent dokument Quick Actions Hurtige handlinger + Rearrange Omarrangér + Select All Artboards Markér alle tegnebrætter + Switch Orientation Skift retning Rearrange All Omarrangér alle Rulers & Grids Linealer og gitre Select a text object Markér et tekstobjekt @@ -3227,6 +3230,7 @@ theme Appearance Udseende Character / Paragraph Tegn / Afsnit Copy Artboard Kopiér kunstplade Cut Artboard Klip kunstplade + Rename Artboards Omdøb tegnebrætter Move Artboard Up/Down Flyt kunstplade op/ned Add Anchor (Pen) Tilføj anker (Pen) Add Anchor Point Tilføj ankerpunkt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d28aa09df..a75ab4aa8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -866,6 +866,7 @@ font-technology Variable Variabel Duplicate Artboard Zeichenfläche duplizieren Copy Artboard Zeichenfläche kopieren Cut Artboard Zeichenfläche ausschneiden + Rename Artboards Zeichenflächen umbenennen Duplicate Artboards Zeichenflächen duplizieren Duplicate Brush Pinsel duplizieren Duplicate Character Style Zeichenformat duplizieren @@ -2529,6 +2530,9 @@ digits Malayalam Malayalam Real World: Reale Welt: Real-time Drawing and Editing Zeichnen und Bearbeiten in Echtzeit Rearrange All Artboards Alle Zeichenflächen neu anordnen + Rearrange Neu anordnen + Select All Artboards Alle Zeichenflächen auswählen + Switch Orientation Ausrichtung wechseln Rearrange All Alle neu anordnen Layout Order: Layoutreihenfolge: Change to Left-to-Right Layout In Layout von links nach rechts ändern diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 16f80c730..d702f7da1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -844,6 +844,7 @@ font-technology Variable Μεταβλητές Duplicate Artboard Δημιουργία αντιγράφου πίνακα σχεδίασης Copy Artboard Αντιγραφή πίνακα σχεδίασης Cut Artboard Αποκοπή πίνακα σχεδίασης + Rename Artboards Μετονομασία πινάκων σχεδίασης Duplicate Artboards Δημιουργία αντιγράφων πινάκων σχεδίασης Duplicate Brush Δημιουργία αντιγράφου πινέλου Duplicate Character Style Δημιουργία αντιγράφου στυλ χαρακτήρων @@ -3160,6 +3161,9 @@ digits Malayalam Μαλαγιάλαμ Real World: Πραγματικός κόσμος: Real-time Drawing and Editing Σχεδίαση και επεξεργασία σε πραγματικό χρόνο Rearrange All Artboards Αναδιάταξη όλων των πινάκων σχεδίασης + Rearrange Αναδιάταξη + Select All Artboards Επιλογή όλων των πινάκων σχεδίασης + Switch Orientation Αλλαγή προσανατολισμού Rearrange All Αναδιάταξη όλων Layout Order: Σειρά διάταξης: Change to Left-to-Right Layout Αλλαγή σε διάταξη από αριστερά προς τα δεξιά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fafd0122e..ed4ec9490 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -887,6 +887,7 @@ font-technology Variable Variables Duplicate Artboard Duplicar mesa de trabajo Copy Artboard Copiar mesa de trabajo Cut Artboard Cortar mesa de trabajo + Rename Artboards Cambiar nombre de mesas de trabajo Duplicate Artboards Duplicar mesas de trabajo Duplicate Brush Duplicar pincel Duplicate Character Style Duplicar estilo de carácter @@ -2572,6 +2573,9 @@ digits Malayalam Malayálam Real World: Mundo real: Real-time Drawing and Editing Dibujo y edición en tiempo real Rearrange All Artboards Reorganizar todas las mesas de trabajo + Rearrange Reorganizar + Select All Artboards Seleccionar todas las mesas de trabajo + Switch Orientation Cambiar orientación Rearrange All Reorganizar todo Layout Order: Orden de diseño: Change to Left-to-Right Layout Cambiar a diseño de izquierda a derecha diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 9c0d61be2..166c750ad 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1911,6 +1911,9 @@ digits Malayalam Malajalam More Paragraph options Lisää kappaleasetuksia No document open Ei avointa asiakirjaa Quick Actions Pikatoiminnot + Rearrange Järjestä uudelleen + Select All Artboards Valitse kaikki piirtoalustat + Switch Orientation Vaihda suuntaa Rearrange All Järjestä kaikki uudelleen Rulers & Grids Viivaimet ja ruudukot Select a text object Valitse tekstiobjekti @@ -3423,6 +3426,7 @@ theme Appearance Ulkoasu Character / Paragraph Merkki / kappale Copy Artboard Kopioi piirtoalusta Cut Artboard Leikkaa piirtoalusta + Rename Artboards Nimeä piirtoalustat uudelleen Move Artboard Up/Down Siirrä piirtoalustaa ylös/alas Add Anchor (Pen) Lisää ankkuri (kynä) Add Anchor Point Lisää ankkuripiste diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2da80b01c..c248c9405 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -890,6 +890,7 @@ font-technology Variable Variables Duplicate Artboard Dupliquer le plan de travail Copy Artboard Copier le plan de travail Cut Artboard Couper le plan de travail + Rename Artboards Renommer les plans de travail Duplicate Artboards Dupliquer les plans de travail Duplicate Brush Dupliquer la forme Duplicate Character Style Dupliquer le style de caractère @@ -2564,6 +2565,9 @@ digits Malayalam Malayalam Real World: Monde réel : Real-time Drawing and Editing Dessin et édition en temps réel Rearrange All Artboards Réorganiser tous les plans de travail + Rearrange Réorganiser + Select All Artboards Sélectionner tous les plans de travail + Switch Orientation Changer d’orientation Rearrange All Tout réorganiser Layout Order: Ordre de disposition : Change to Left-to-Right Layout Passer à la disposition de gauche à droite diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index d7235790f..e80177051 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1077,6 +1077,7 @@ Customize… अनुकूलित करें… Cut काटें Cut Artboard कट आर्टबोर्ड + Rename Artboards आर्टबोर्ड्स का नाम बदलें Cut Path कट पाथ Cut Path at Selected Anchor Points सिलेक्टेड एंकर अंक पर पाथ काटें Cutout कटआउट @@ -3487,6 +3488,9 @@ Real name वास्तविक नाम Real World: रियल वर्ल्ड: Real-time Drawing and Editing रियल टाइम ड्राइंग और संपादन + Rearrange फिर से व्यवस्थित करें + Select All Artboards सभी आर्टबोर्ड सिलेक्ट करें + Switch Orientation ओरिएंटेशन स्विच करें Rearrange All सभी को फिर से व्यवस्थित करें Rearrange All Artboards सभी आर्टबोर्ड को फिर से व्यवस्थित करें Rearrange All Artboards… सभी आर्टबोर्ड को पुनर्व्यवस्थित करें… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 9e313975e..6c0037461 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -903,6 +903,7 @@ font-technology Variable Változó Customize… Testreszabás… Cut Kivágás Cut Artboard Rajztábla kivágása + Rename Artboards Rajztáblák átnevezése Cut Path Útvonal elvágása Cut Path at Selected Anchor Points Útvonal elvágása a kijelölt horgonypontoknál Cyan Cián @@ -3114,6 +3115,9 @@ mark-style Japanese Japán Real name Valódi név Real World: Valós méret: Real-time Drawing and Editing Valós idejű rajzolás és szerkesztés + Rearrange Átrendezés + Select All Artboards Összes rajztábla kijelölése + Switch Orientation Tájolás váltása Rearrange All Összes átrendezése Rearrange All Artboards Összes rajztábla átrendezése Rearrange All Artboards… Összes rajztábla átrendezése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0a6c910fc..33673568f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1756,6 +1756,9 @@ digits Malayalam Malayalam More Paragraph options Opsi paragraf lainnya No document open Tidak ada dokumen terbuka Quick Actions Tindakan cepat + Rearrange Atur ulang + Select All Artboards Seleksi semua artboard + Switch Orientation Ganti orientasi Rearrange All Atur ulang semua Rulers & Grids Penggaris & kisi Select a text object Pilih objek teks @@ -3227,6 +3230,7 @@ theme Appearance Tampilan Character / Paragraph Karakter / Paragraf Copy Artboard Salin artboard Cut Artboard Potong artboard + Rename Artboards Ganti nama artboard Move Artboard Up/Down Pindahkan artboard ke atas/bawah Add Anchor (Pen) Tambah jangkar (pena) Add Anchor Point Tambahkan titik jangkar diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3459374ca..1c22a0ac1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -887,6 +887,7 @@ font-technology Variable Variabili Duplicate Artboard Duplica tavola da disegno Copy Artboard Copia tavola da disegno Cut Artboard Taglia tavola da disegno + Rename Artboards Rinomina tavole da disegno Duplicate Artboards Duplica tavole da disegno Duplicate Brush Duplica pennello Duplicate Character Style Duplica stile carattere @@ -2605,6 +2606,9 @@ digits Malayalam Malayalam Real World: Mondo reale: Real-time Drawing and Editing Disegno e modifica in tempo reale Rearrange All Artboards Ridisponi tutte le tavole da disegno + Rearrange Ridisponi + Select All Artboards Seleziona tutte le tavole da disegno + Switch Orientation Cambia orientamento Rearrange All Ridisponi tutto Layout Order: Ordine del layout: Change to Left-to-Right Layout Passa al layout da sinistra a destra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 715f1fc46..ab9c291f4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1441,6 +1441,7 @@ digits Malayalam マラヤーラム数字 Duplicate Artboard アートボードを複製 Copy Artboard アートボードをコピー Cut Artboard アートボードをカット + Rename Artboards アートボード名を変更 Duplicate Artboards アートボードを複製 Duplicate Brush ブラシを複製 Duplicate Character Style 文字スタイルを複製 @@ -2619,6 +2620,9 @@ mark-style Japanese 日本式 Real World: 実寸: Real-time Drawing and Editing リアルタイム描画および編集 Rearrange All Artboards すべてのアートボードを再配置 + Rearrange 再配置 + Select All Artboards すべてのアートボードを選択 + Switch Orientation 方向を切り替え Rearrange All すべて再配置 Layout Order: レイアウト順序: Change to Left-to-Right Layout 左から右のレイアウトに変更 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2c8075173..96005c9cd 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1666,6 +1666,9 @@ digits Malayalam მალაიალამური More Paragraph options აბზაცის სხვა პარამეტრები No document open დოკუმენტი გახსნილი არ არის Quick Actions სწრაფი მოქმედებები + Rearrange გადალაგება + Select All Artboards ყველა სამუშაო დაფის მონიშვნა + Switch Orientation ორიენტაციის გადართვა Rearrange All ყველას გადალაგება Rulers & Grids სახაზავები და ბადეები Select a text object მონიშნეთ ტექსტური ობიექტი @@ -3047,6 +3050,7 @@ theme Appearance იერსახე Character / Paragraph სიმბოლო / აბზაცი Copy Artboard სამუშაო დაფის კოპირება Cut Artboard სამუშაო დაფის ამოჭრა + Rename Artboards სამუშაო დაფების სახელის შეცვლა Move Artboard Up/Down სამუშაო დაფის აწევა/ჩამოწევა Add Anchor (Pen) საყრდენი წერტილის დამატება (კალამი) Add Anchor Point საყრდენი წერტილის დამატება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 01c96a738..405899006 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1431,6 +1431,7 @@ digits Malayalam 말라얄람어 Duplicate Artboard 아트보드 복제 Copy Artboard 아트보드 복사 Cut Artboard 아트보드 잘라내기 + Rename Artboards 아트보드 이름 바꾸기 Duplicate Artboards 아트보드 복제 Duplicate Brush 브러시 복제 Duplicate Character Style 문자 스타일 복제 @@ -2609,6 +2610,9 @@ mark-style Japanese 일본식 Real World: 실제 크기: Real-time Drawing and Editing 실시간 그리기 및 편집 Rearrange All Artboards 모든 아트보드 재정렬 + Rearrange 재정렬 + Select All Artboards 모든 아트보드 선택 + Switch Orientation 방향 전환 Rearrange All 모두 재정렬 Layout Order: 배치 순서: Change to Left-to-Right Layout 왼쪽에서 오른쪽 배치로 변경 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index fe34d035d..fc33adb90 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1770,6 +1770,9 @@ digits Malayalam Malayalam More Paragraph options Meer alineaopties No document open Geen document geopend Quick Actions Snelle acties + Rearrange Herschikken + Select All Artboards Alle tekengebieden selecteren + Switch Orientation Stand wisselen Rearrange All Alles herschikken Rulers & Grids Linialen & rasters Select a text object Selecteer een tekstobject @@ -3241,6 +3244,7 @@ theme Appearance Vormgeving Character / Paragraph Teken / alinea Copy Artboard Tekenbord kopiëren Cut Artboard Tekenbord knippen + Rename Artboards Tekengebieden hernoemen Move Artboard Up/Down Tekenbord omhoog/omlaag verplaatsen Add Anchor (Pen) Anker toevoegen (Pen) Add Anchor Point Ankerpunt toevoegen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6bb9ee44c..412ec2657 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1869,6 +1869,9 @@ digits Malayalam Malajalam More Paragraph options Więcej opcji akapitu No document open Brak otwartego dokumentu Quick Actions Szybkie operacje + Rearrange Przestaw + Select All Artboards Zaznacz wszystkie obszary kompozycji + Switch Orientation Przełącz orientację Rearrange All Przestaw wszystkie Rulers & Grids Linijki i siatki Select a text object Zaznacz obiekt tekstowy @@ -3364,6 +3367,7 @@ theme Appearance Wygląd Character / Paragraph Znak / akapit Copy Artboard Kopiuj obszar kompozycji Cut Artboard Wytnij obszar kompozycji + Rename Artboards Zmień nazwy obszarów kompozycji Move Artboard Up/Down Przesuń obszar kompozycji w górę/dół Add Anchor (Pen) Dodaj punkt (pióro) Add Anchor Point Dodaj punkt kontrolny diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index cbdda2921..4ed60aace 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -790,6 +790,7 @@ Duplicate Artboard Duplicar Mesa de Trabalho Copy Artboard Copiar Mesa de Trabalho Cut Artboard Recortar Mesa de Trabalho + Rename Artboards Renomear Mesas de Trabalho Duplicate Artboards Duplicar Mesas de Trabalho Duplicate Brush Duplicar Pincel Duplicate Character Style Duplicar Estilo de Caractere @@ -2139,6 +2140,9 @@ digits Malayalam Malaiala Real World: Mundo Real: Real-time Drawing and Editing Desenho e Edição em Tempo Real Rearrange All Artboards Reorganizar Todas as Mesas de Trabalho + Rearrange Reorganizar + Select All Artboards Selecionar Todas as Mesas de Trabalho + Switch Orientation Trocar Orientação Rearrange All Reorganizar Tudo Layout Order: Ordem do Layout: Change to Left-to-Right Layout Mudar para Layout da Esquerda para a Direita diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index df48bf8ab..e61c6c73a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -885,6 +885,7 @@ font-technology Variable Вариативные Duplicate Artboard Дублировать монтажную область Copy Artboard Копировать монтажную область Cut Artboard Вырезать монтажную область + Rename Artboards Переименовать монтажные области Duplicate Artboards Дублировать монтажные области Duplicate Brush Дублировать кисть Duplicate Character Style Дублировать стиль символа @@ -2600,6 +2601,9 @@ digits Malayalam Малаялам Real World: Реальный мир: Real-time Drawing and Editing Рисование и редактирование в реальном времени Rearrange All Artboards Переставить все монтажные области + Rearrange Переставить + Select All Artboards Выделить все монтажные области + Switch Orientation Переключить ориентацию Rearrange All Переставить все Layout Order: Порядок макета: Change to Left-to-Right Layout Переключить на макет слева направо diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 26fc08841..f606a4bda 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -874,6 +874,7 @@ font-technology Variable Variabla Duplicate Artboard Duplicera rityta Copy Artboard Kopiera rityta Cut Artboard Klipp ut rityta + Rename Artboards Byt namn på ritytor Duplicate Artboards Duplicera ritytor Duplicate Brush Duplicera pensel Duplicate Character Style Duplicera teckenstil @@ -2509,6 +2510,9 @@ digits Malayalam Malayalam Real World: Verkligt mått: Real-time Drawing and Editing Ritning och redigering i realtid Rearrange All Artboards Ordna om alla ritytor + Rearrange Ordna om + Select All Artboards Markera alla ritytor + Switch Orientation Byt orientering Rearrange All Ordna om alla Layout Order: Layoutordning: Change to Left-to-Right Layout Byt till layout vänster till höger diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c296f66aa..24ec88697 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -866,6 +866,7 @@ font-technology Variable Değişken Duplicate Artboard Çalışma Yüzeyini Çoğalt Copy Artboard Çalışma Yüzeyini Kopyala Cut Artboard Çalışma Yüzeyini Kes + Rename Artboards Çalışma Yüzeylerini Yeniden Adlandır Duplicate Artboards Çalışma Yüzeylerini Çoğalt Duplicate Brush Yinelenen Fırça Duplicate Character Style Yinelenen Karakter Stili @@ -2382,6 +2383,9 @@ digits Malayalam Malayalam Real World: Gerçek Dünya: Real-time Drawing and Editing Gerçek Zamanlı Çizim ve Düzenleme Rearrange All Artboards Tüm Çalışma Yüzeylerini Yeniden Düzenleme + Rearrange Yeniden Düzenle + Select All Artboards Tüm Çalışma Yüzeylerini Seç + Switch Orientation Yönü Değiştir Rearrange All Tümünü Yeniden Düzenle Layout Order: Yerleşim Sırası: Change to Left-to-Right Layout Soldan Sağa Düzene Geç diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5f823ba12..e4d8ebfac 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -881,6 +881,7 @@ font-technology Variable Варіативні Duplicate Artboard Дублювати монтажну область Copy Artboard Копіювати монтажну область Cut Artboard Вирізати монтажну область + Rename Artboards Перейменувати монтажні області Duplicate Artboards Дублювати монтажні області Duplicate Brush Дублювати пензель Duplicate Character Style Дублювати стиль символу @@ -2597,6 +2598,9 @@ digits Malayalam Малаялам Real World: Реальний світ: Real-time Drawing and Editing Малювання та редагування у реальному часі Rearrange All Artboards Переставити всі монтажні області + Rearrange Переставити + Select All Artboards Виділити всі монтажні області + Switch Orientation Переключити орієнтацію Rearrange All Переставити все Layout Order: Порядок макету: Change to Left-to-Right Layout Переключити на макет зліва направо diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7d157502c..6f082606c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -799,6 +799,7 @@ font-technology Variable Biến thiên Customize… Tùy chỉnh… Cut Cắt Cut Artboard Cắt bảng vẽ + Rename Artboards Đổi tên các bảng vẽ Cut Path Cắt đường dẫn Cut Path at Selected Anchor Points Cắt đường dẫn tại các điểm neo đã chọn Cyan Xanh lơ @@ -2894,6 +2895,9 @@ mark-style Japanese Kiểu Nhật Real name Tên thật Real World: Thực tế: Real-time Drawing and Editing Vẽ và chỉnh sửa theo thời gian thực + Rearrange Sắp xếp lại + Select All Artboards Chọn tất cả các bảng vẽ + Switch Orientation Chuyển hướng Rearrange All Sắp xếp lại tất cả Rearrange All Artboards Sắp xếp lại tất cả bảng vẽ Rearrange All Artboards… Sắp xếp lại tất cả bảng vẽ… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 643b92218..eed6a5a0d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -829,6 +829,7 @@ Duplicate Artboard 复制画板 Copy Artboard 拷贝画板 Cut Artboard 剪切画板 + Rename Artboards 重命名画板 Duplicate Artboards 复制画板 Duplicate Brush 复制画笔 Duplicate Character Style 制作字符样式副本 @@ -2478,6 +2479,9 @@ digits Malayalam 马拉雅拉姆数字 Real World: 真实世界: Real-time Drawing and Editing 实时绘图和编辑 Rearrange All Artboards 重新排列所有画板 + Rearrange 重新排列 + Select All Artboards 选择所有画板 + Switch Orientation 切换方向 Rearrange All 全部重新排列 Layout Order: 布局顺序: Change to Left-to-Right Layout 更改为由左至右版面配置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 945626915..0c3af38f9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -849,6 +849,7 @@ font-technology Variable 可變 Duplicate Artboard 製作工作區域複本 Copy Artboard 複製工作區域 Cut Artboard 剪下工作區域 + Rename Artboards 重新命名工作區域 Duplicate Artboards 製作工作區域複本 Duplicate Brush 製作筆刷複本 Duplicate Character Style 製作字元樣式複本 @@ -3467,6 +3468,9 @@ digits Malayalam 馬來亞拉姆數字 Real World: 真實世界: Real-time Drawing and Editing 即時繪圖與編輯 Rearrange All Artboards 重新排列所有工作區域 + Rearrange 重新排列 + Select All Artboards 選取所有工作區域 + Switch Orientation 切換方向 Rearrange All 全部重新排列 Layout Order: 版面配置順序: Change to Left-to-Right Layout 變更為由左至右版面配置 diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index 3af95c607..bc191593a 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -8,6 +8,9 @@ use super::{Item, Sep, Value, VectorcraftApp, c, cp, json, sub, widgets}; pub fn context_items(app: &VectorcraftApp) -> Vec { use vectorcraft_doc::NodeKind; let Some(st) = app.session.active() else { return vec![] }; + if app.session.tool_id() == "artboard" { + return prune(app, artboard_items(app)); + } let roots: Vec<&vectorcraft_doc::Node> = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).collect(); let any = |f: fn(&NodeKind) -> bool| roots.iter().any(|n| f(&n.kind)); let several = roots.len() >= 2; @@ -30,40 +33,7 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { Sep, ]); } - // Managing artboards from the canvas: with the Artboard tool, the menu locks and unlocks them - // (`artboard.setProps` with `locked` for each artboard named; an entry with none to change is left out). - if app.session.tool_id() == "artboard" { - let n = st.doc.artboards.len(); - let sel = crate::panels::artboards::picked(app, n); - let others: Vec = (0..n).filter(|i| !sel.contains(i)).collect(); - let entry = |label, idx: Vec, locked: bool| { - let list: Vec = idx.iter().map(|i| json!({ "index": i, "locked": locked })).collect(); - (!list.is_empty()).then(|| cp(label, "artboard.setProps", json!({ "artboards": list }))) - }; - let lock: Vec = - [entry("Lock Selected", sel.clone(), true), entry("Lock All Except Selected", others, true), entry("Lock All", (0..n).collect(), true)] - .into_iter() - .flatten() - .collect(); - let unlock: Vec = [entry("Unlock Selected", sel, false), entry("Unlock All", (0..n).collect(), false)].into_iter().flatten().collect(); - for (label, items) in [("Lock Artboards", lock), ("Unlock Artboards", unlock)] { - if !items.is_empty() { - v.push(sub(label, items)); - } - } - v.push(Sep); - } - // The locked objects under the pointer at the right-click, each unlocked as the Layers panel's - // lock column does. `unlock_item_label` shows each object's name. - let unlock: Vec = app - .ui - .context_locked - .iter() - .filter(|(uid, _)| *uid == st.uid) - .flat_map(|(_, ids)| ids) - .filter(|id| st.doc.node(**id).is_some_and(|n| n.locked)) - .map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false }))) - .collect(); + let unlock = unlock_items(app); if !unlock.is_empty() { v.extend([sub("Unlock", unlock), Sep]); } @@ -138,6 +108,100 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { prune(app, v) } +/// The locked objects under the pointer at the right-click, each unlocked as the Layers panel's +/// lock column does. `unlock_item_label` shows each object's name. +fn unlock_items(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + app.ui + .context_locked + .iter() + .filter(|(uid, _)| *uid == st.uid) + .flat_map(|(_, ids)| ids) + .filter(|id| st.doc.node(**id).is_some_and(|n| n.locked)) + .map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false }))) + .collect() +} + +/// The canvas context menu with the Artboard tool, in place of the entries for objects and the view: the list and order of the public +/// [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) +/// page, with the page's Lock Artboards submenu as two, Lock Artboards and Unlock Artboards. `docs/mcp.md` +/// names the entries that act on the artboards the tool has selected. Exit Isolation Mode and the +/// Unlock submenu for locked objects under the pointer follow them. +fn artboard_items(app: &VectorcraftApp) -> Vec { + use crate::panels::artboards as boards; + let Some(st) = app.session.active() else { return vec![] }; + let all = &st.doc.artboards; + let n = all.len(); + let is_locked = |i: &usize| all.get(*i).is_some_and(|a| a.locked); + let sel: Vec = boards::picked(app, n).into_iter().filter(|i| *i < n).collect(); + let free: Vec = sel.iter().copied().filter(|i| !is_locked(i)).collect(); + let facts = boards::open_menu_facts(app); + let mut v = vec![]; + // Cut refuses a locked artboard and every artboard of the document. + if !sel.is_empty() && free.len() == sel.len() && sel.len() < n { + v.push(c("Cut", "artboard.cut")); + } + if !sel.is_empty() { + v.push(c("Copy", "artboard.copy")); + } + v.push(match facts { + // Copied artboards go to the right-click's point, and so do copied objects. + Some(f) => cp("Paste", "edit.paste", json!({ "center": [f.at.x, f.at.y], "artboardsAt": [f.at.x, f.at.y] })), + None => c("Paste", "edit.paste"), + }); + v.extend([Sep, c("Undo", "edit.undo"), c("Redo", "edit.redo"), Sep, c("New Artboard", "artboard.new")]); + if !sel.is_empty() { + v.extend([c("Duplicate Artboards", "artboard.duplicate"), c("Rename", "artboard.rename")]); + } + let deleted = boards::delete_targets(app, n); + if !deleted.is_empty() { + v.push(cp("Delete Artboards", "artboard.delete", json!({ "indices": deleted }))); + } + v.extend([c("Delete Empty Artboards", "artboard.deleteEmpty"), Sep]); + if sel.len() < n { + v.push(c("Select All Artboards", "artboard.selectAll")); + } + // `artboard.setProps` with `locked` for each artboard it changes; an entry with none is left out. + let others: Vec = (0..n).filter(|i| !sel.contains(i)).collect(); + let entry = |label, idx: Vec, locked: bool| { + let list: Vec = idx.iter().filter(|i| is_locked(i) != locked).map(|i| json!({ "index": i, "locked": locked })).collect(); + (!list.is_empty()).then(|| cp(label, "artboard.setProps", json!({ "artboards": list }))) + }; + let lock: Vec = + [entry("Lock Selected", sel.clone(), true), entry("Lock All Except Selected", others, true), entry("Lock All", (0..n).collect(), true)] + .into_iter() + .flatten() + .collect(); + let unlock: Vec = + [entry("Unlock Selected", sel.clone(), false), entry("Unlock All", (0..n).collect(), false)].into_iter().flatten().collect(); + for (label, items) in [("Lock Artboards", lock), ("Unlock Artboards", unlock)] { + if !items.is_empty() { + v.push(sub(label, items)); + } + } + v.push(Sep); + if n > 1 { + v.push(c("Rearrange", "artboard.rearrange")); + } + if facts.map_or_else(|| boards::fit_has_art(app, &free), |f| f.any_art) { + v.push(c("Fit to Artwork Bounds", "artboard.fitToArt")); + } + if free.iter().filter_map(|i| all.get(*i)).any(|a| (a.rect.width() - a.rect.height()).abs() > 1e-6) { + v.push(c("Switch Orientation", "artboard.switchOrientation")); + } + if !sel.is_empty() { + v.extend([cp("Artboard Options", "tool.options", json!({ "tool": "artboard" })), Sep, c("Export", "file.exportForScreens")]); + } + if st.isolation.is_some() { + v.extend([Sep, c("Exit Isolation Mode", "object.exitIsolation")]); + } + let locked_art = unlock_items(app); + if !locked_art.is_empty() { + v.extend([Sep, sub("Unlock", locked_art)]); + } + v +} + /// `items` without the commands that can't run now (Undo and Redo stay, greyed out), submenus /// left empty, and separators that no longer separate anything. fn prune(app: &VectorcraftApp, items: Vec) -> Vec { diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 5b0d0f3c4..073b1130e 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -80,6 +80,7 @@ pub(super) fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static st crate::addons::typetools::COMMAND => (crate::addons::typetools::KIND, crate::addons::typetools::fields(app)), crate::addons::select::COMMAND => (crate::addons::select::KIND, crate::addons::select::fields(app)), "artboard.rearrange" => (crate::dialogs::rearrange_artboards::KIND, crate::dialogs::rearrange_artboards::open_fields(app)), + "artboard.rename" => (crate::dialogs::rename_artboards::KIND, crate::dialogs::rename_artboards::open_fields(app)), _ => return None, }) } @@ -173,6 +174,12 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + if let Some(r) = crate::panels::artboards::run_menu_item(app, id, &p) { + if let Err(e) = r { + app.status(e); + } + return; + } // A Paste the user gives decodes a bitmap off the UI thread ([`crate::placing::adopt_for_paste`]). app.placing.for_user = id.starts_with("edit.paste"); let r = app.run(id, p); diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 5820ded94..b8d314346 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -217,6 +217,11 @@ pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> { match (id, p.get("panel").and_then(Value::as_str)) { ("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel), ("effect.dialog" | "effect.apply", _) => p.get("effect").and_then(Value::as_str).and_then(crate::shortcut_editor::effect_shortcut), + // The Artboard tool's canvas menu passes the point its Paste goes to. + ("edit.paste", _) => shortcut_of(id), + // That menu's Cut and Copy: with the tool chosen, the keys run `edit.cut` and `edit.copy`, which do the same. + ("artboard.cut", _) => shortcut_of("edit.cut"), + ("artboard.copy", _) => shortcut_of("edit.copy"), _ if p.is_null() => shortcut_of(id), _ => None, } @@ -234,6 +239,16 @@ pub const CONTEXT_LABELS: &[&str] = &[ "Unlock Artboards", "Unlock Selected", "Unlock", + "New Artboard", + "Duplicate Artboards", + "Rename", + "Delete Artboards", + "Delete Empty Artboards", + "Select All Artboards", + "Rearrange", + "Switch Orientation", + "Artboard Options", + "Export", "Make Clipping Mask", "Release Clipping Mask", "Make Compound Path", diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index a912e1df2..dbee7082f 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -712,6 +712,7 @@ fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::layers::rename(app, p), "ui.newLayer" => crate::dialogs::layer_options::open_new(app, p), "ui.newArtboard" => crate::dialogs::artboard_options::open_new(app), + "artboard.selectAll" => crate::panels::artboards::select_all_command(app), "ui.layersPanelOptions" => crate::dialogs::layers_panel_options::open(app), "ui.navigatorPanelOptions" => crate::panels::navigator::set_options(app, p), "ui.layersExpand" => crate::panels::layers::expand(app, p), diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index aa533b30b..963045722 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -187,6 +187,12 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { if SUBJECT_COMMANDS.contains(&id) { return has_subject(app); } + // The Artboard tool's canvas menu measured this when it opened. + if id == "artboard.deleteEmpty" + && let Some(facts) = crate::panels::artboards::open_menu_facts(app) + { + return facts.any_empty; + } if let Some(c) = vectorcraft_engine::find_command(id) { // The system clipboard's contents can be pasted with an empty internal clipboard. return (c.enabled)(&app.session).is_ok() || app.system_paste && id.starts_with("edit.paste"); @@ -245,7 +251,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { (d.kind == crate::dialogs::char_style_options::KIND || (d.kind == crate::dialogs::new_text_style::KIND && !d.bool("__para"))) && crate::dialogs::char_style_options::can_reset(d) }), - "ui.newArtboard" => app.session.active().is_some(), + "ui.newArtboard" | "artboard.selectAll" => app.session.active().is_some(), "ui.sampleScreenColor" => crate::screen_color::available(app), "ui.newBrush" => app.session.active().is_some(), "ui.brushOptions" => crate::dialogs::brush_options::available(app), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 39ebedf24..988abad5b 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -873,6 +873,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", ), + ( + "artboard.selectAll", + "Select All Artboards", + "", + "{} the Artboard tool, chosen first when another tool is in use, selects every artboard, and the active artboard stays active; the art's selection stays as it is, and no undo step is recorded → {selected: [index…]} (the active artboard first)", + ), ( "ui.layersPanelOptions", "Panel Options…", @@ -1044,7 +1050,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.menuDialog", "Menu Dialog", "", - "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|path.averageJoin|object.path.offsetPath|object.path.simplify|object.path.smooth|object.path.splitIntoGrid|object.vectorHalftone|artboard.rearrange|artboard.rename|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options|addon.arrange.grid|addon.arrange.replace|addon.arrange.size} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, rearrangeArtboards, renameArtboards, envelopeWarp, envelopeMesh, envelopeOptions, arrangeGrid, arrangeReplace, arrangeSize; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; Transform Each opens on the values it was last confirmed with (not `copy`), and its Copy button confirms with `copy: true`; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", ), ( "ui.widthPointEdit", diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 438cc6ba6..d02f5f028 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -245,20 +245,10 @@ pub(crate) fn set_props( } } -/// Rename the artboards of `indices` in one undo step (`artboard.setProps`): one artboard takes -/// `name` (trimmed), and several take it numbered in panel order, `name-1`, `name-2` and so on. +/// Rename the artboards of `indices` in one undo step (`artboard.rename`): one artboard takes `name` +/// (trimmed), and several take it numbered in panel order, `name-1`, `name-2` and so on. fn rename(app: &mut VectorcraftApp, indices: &[usize], name: &str) -> Result { - let name = name.trim(); - let mut all = indices.to_vec(); - all.sort_unstable(); - all.dedup(); - let several = all.len() > 1; - let numbered = |k: usize| if several { format!("{name}-{}", k + 1) } else { name.to_string() }; - let entries = all.iter().enumerate().map(|(k, i)| json!({"index": i, "name": numbered(k)})).collect(); - match set_props_params(json!({}), entries) { - Some(p) => app.run("artboard.setProps", p), - None => Ok(Value::Null), - } + app.run("artboard.rename", json!({"indices": indices, "name": name})) } /// Open the name field on artboard `i`'s row. The name typed there goes to the artboards @@ -268,32 +258,126 @@ fn start_rename(ctx: &egui::Context, app: &VectorcraftApp, i: usize) { set_pstate(ctx, "ab-edit-all", renamed_with(app, i)); } -/// Delete the picked artboards that aren't locked and select a remaining one. -pub(crate) fn delete_selected(app: &mut VectorcraftApp) { - let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); +/// The artboards Delete Artboards deletes (of `n`): the picked ones that aren't locked, without +/// artboard 1 when that would be all of them. Empty when none can go, since one is always left. +pub(crate) fn delete_targets(app: &VectorcraftApp, n: usize) -> Vec { let mut all = deletable(app, n); - if all.is_empty() { - return; - } if n > 1 && all.len() == n { // Deleting all artboards keeps artboard 1 (the public Delete artboards guide). all.retain(|i| *i != 0); } - if n > all.len() && app.run("artboard.delete", json!({ "indices": all })).is_ok() { + if n > all.len() { all } else { vec![] } +} + +/// Delete the picked artboards that aren't locked and select a remaining one. +pub(crate) fn delete_selected(app: &mut VectorcraftApp) { + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + let all = delete_targets(app, n); + if !all.is_empty() && app.run("artboard.delete", json!({ "indices": all })).is_ok() { select(app, all.first().copied().unwrap_or(0).saturating_sub(1)); } } /// Artboards panel › Delete Empty Artboards: delete the artboards with no artwork, then make the /// active artboard, where it is now, active again, or else the nearest one before it that is left. -pub(crate) fn delete_empty(app: &mut VectorcraftApp) { +pub(crate) fn delete_empty(app: &mut VectorcraftApp) -> Result { let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); let active = selected(app, n); - let Ok(r) = app.run("artboard.deleteEmpty", json!({})) else { return }; + let r = app.run("artboard.deleteEmpty", json!({}))?; let deleted: Vec = r["deleted"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(|i| i as usize).collect(); // The last artboard up to the active one that is left, numbered as it is now. let kept = (0..=active).rev().find(|i| !deleted.contains(i)).unwrap_or(0); select(app, kept.saturating_sub(deleted.iter().filter(|d| **d < kept).count())); + Ok(r) +} + +/// What the Artboard tool's canvas menu needs to know about the document's art, which takes a walk +/// over it: [`right_click`] measures it once, and the menu reads it on every frame it is open. +#[derive(Clone, Debug)] +pub struct MenuFacts { + uid: u64, + revision: u64, + picked: Vec, + /// The right-click's point, in document coordinates: where the menu's Paste puts artboards. + pub(crate) at: vectorcraft_geom::Point, + /// Whether Delete Empty Artboards has an artboard to delete. + pub(crate) any_empty: bool, + /// Whether Fit to Artwork Bounds has art to fit a selected artboard to. + pub(crate) any_art: bool, +} + +/// Whether an unlocked artboard of `indices` has art that Fit to Artwork Bounds fits it to. +pub(crate) fn fit_has_art(app: &VectorcraftApp, indices: &[usize]) -> bool { + app.session.active().is_some_and(|st| { + indices + .iter() + .filter_map(|i| st.doc.artboards.get(*i)) + .any(|a| !a.locked && vectorcraft_render::encode::art_bounds_on(&st.doc, a.rect).is_some()) + }) +} + +/// A right-click on the canvas at `p` with the Artboard tool: an artboard under the pointer that +/// isn't selected becomes the only selected one, and the menu's facts are measured. +pub(crate) fn right_click(app: &mut VectorcraftApp, p: vectorcraft_geom::Point) { + let Some(st) = app.session.active() else { return }; + let (uid, n, hit) = (st.uid, st.doc.artboards.len(), st.doc.artboard_at(p)); + if let Some(i) = hit.filter(|i| !picked(app, n).contains(i)) { + select(app, i); + } + let all = picked(app, n); + let any_empty = vectorcraft_engine::find_command("artboard.deleteEmpty").is_some_and(|c| (c.enabled)(&app.session).is_ok()); + let any_art = fit_has_art(app, &all); + let revision = app.session.active().map_or(0, |st| st.revision); + app.ui.context_artboards = Some(MenuFacts { uid, revision, picked: all, at: p, any_empty, any_art }); +} + +/// The facts of the open canvas menu, while they still describe the document and the selection. +pub(crate) fn open_menu_facts(app: &VectorcraftApp) -> Option<&MenuFacts> { + let st = app.session.active()?; + let n = st.doc.artboards.len(); + app.ui.context_artboards.as_ref().filter(|f| f.uid == st.uid && f.revision == st.revision && f.picked == picked(app, n)) +} + +/// Run an artboard command chosen from a menu or the command palette while the Artboard tool is in +/// use, with the view and the tool's selection following it as they do in the Artboards panel: a +/// new artboard becomes active, the copies are selected, and a deletion makes a remaining artboard +/// active. None for any other command or tool. +pub(crate) fn run_menu_item(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + if app.session.tool_id() != "artboard" { + return None; + } + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + Some(match id { + "artboard.new" => app.run(id, p.clone()).inspect(|r| { + if let Some(i) = r["index"].as_u64() { + select(app, i as usize); + } + }), + "artboard.duplicate" => { + let all = picked(app, n); + app.run(id, p.clone()).inspect(|r| select_copies(app, &all, r)) + } + "artboard.delete" => app.run(id, p.clone()).inspect(|_| { + let listed = p["indices"].as_array().and_then(|l| l.iter().filter_map(Value::as_u64).min()); + let first = listed.or(p["index"].as_u64()).unwrap_or(0) as usize; + select(app, first.saturating_sub(1)); + }), + "artboard.deleteEmpty" => delete_empty(app), + _ => return None, + }) +} + +/// `artboard.selectAll`: the Artboard tool, chosen first when another tool is in use, selects every +/// artboard → the artboards selected, the active one first. +pub(crate) fn select_all_command(app: &mut VectorcraftApp) -> Result { + if app.session.active().is_none() { + return Err("no document".into()); + } + if app.session.tool_id() != "artboard" { + app.select_tool("artboard"); + } + select_all(app); + Ok(json!({ "selected": app.session.tool_options()["selected"] })) } /// Shift-click on artboard `i`'s row while the Artboard tool is in use: the tool selects the @@ -397,8 +481,15 @@ fn duplicate(app: &mut VectorcraftApp, i: usize) { pub(crate) fn duplicate_picked(app: &mut VectorcraftApp) { let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); let all = picked(app, n); - let active = selected(app, n); let Ok(r) = app.run("artboard.duplicate", json!({ "indices": all })) else { return }; + select_copies(app, &all, &r); +} + +/// After `artboard.duplicate` of the artboards `all` returned `r`: select the copies, the active +/// artboard's copy active. +fn select_copies(app: &mut VectorcraftApp, all: &[usize], r: &Value) { + let n = app.session.active().map_or(0, |d| d.doc.artboards.len()); + let active = selected(app, n); let copies: Vec = r["indices"].as_array().into_iter().flatten().filter_map(Value::as_u64).filter_map(|i| usize::try_from(i).ok()).collect(); let Some(first) = all.iter().position(|i| *i == active).and_then(|k| copies.get(k)).or(copies.first()).copied() else { return }; @@ -619,7 +710,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { start_rename(ui.ctx(), app, if all.contains(&sel) { sel } else { all.first().copied().unwrap_or(sel) }); } if menu_item(ui, tl!("Delete Empty Artboards"), crate::menus::enabled(app, "artboard.deleteEmpty"), false) { - delete_empty(app); + delete_empty(app).ok(); } ui.separator(); if menu_item(ui, tl!("Convert to Artboards"), crate::menus::enabled(app, "artboard.convertToArtboards"), false) { diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index aa92c40e7..c01d82eb1 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -1320,3 +1320,346 @@ fn the_artboard_tools_context_menu_locks_and_unlocks_artboards() { run(&mut app, "Unlock Artboards", "Unlock All"); assert_eq!(locked(&app), [false, false, false]); } + +/// A document of `boards` artboards of 100 × 50 pt with the Artboard tool chosen. +fn menu_app(boards: usize) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 50, "units": "Points", "artboards": boards})).unwrap(); + app.select_tool("artboard"); + app +} + +/// The labels of `items`, submenus by their own label, separators left out. +fn menu_labels(items: &[crate::menus::Item]) -> Vec<&'static str> { + use crate::menus::Item; + items + .iter() + .filter_map(|i| match i { + Item::Cmd(l, ..) | Item::Sub(l, _) => Some(*l), + _ => None, + }) + .collect() +} + +/// The entry of the Artboard tool's canvas menu named `label`, chosen as a click on it chooses it. +fn choose(app: &mut VectorcraftApp, label: &str) { + use crate::menus::Item; + let items = crate::menus::context_items(app); + let Some(Item::Cmd(l, id, p)) = items.iter().find(|i| matches!(i, Item::Cmd(l, ..) if *l == label)) else { panic!("no {label}") }; + let (id, p) = crate::menus::click_target(l, id, p); + crate::menus::invoke(app, &id, p); +} + +fn undo_steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +fn board_names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect() +} + +/// The Artboard tool's canvas menu lists the public page's entries in the page's order, with +/// nothing for objects or the view; another tool's menu lists none of them. +#[test] +fn the_artboard_tools_menu_lists_the_pages_entries_in_order() { + let mut app = menu_app(3); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + app.session.execute("edit.copy", &json!({})).unwrap(); + assert_eq!( + menu_labels(&crate::menus::context_items(&app)), + [ + "Cut", + "Copy", + "Paste", + "Undo", + "Redo", + "New Artboard", + "Duplicate Artboards", + "Rename", + "Delete Artboards", + "Delete Empty Artboards", + "Select All Artboards", + "Lock Artboards", + "Rearrange", + "Fit to Artwork Bounds", + "Switch Orientation", + "Artboard Options", + "Export", + ] + ); + app.select_tool("selection"); + let other = menu_labels(&crate::menus::context_items(&app)); + for label in + ["New Artboard", "Duplicate Artboards", "Delete Artboards", "Select All Artboards", "Lock Artboards", "Rearrange", "Switch Orientation"] + { + assert!(!other.contains(&label), "{label} in {other:?}"); + } +} + +/// An entry whose command can't run is left out: Cut with every artboard selected, or a locked one; +/// Delete Artboards in a document of one; Select All Artboards with all selected; Rearrange with one; +/// Fit to Artwork Bounds without art; Switch Orientation on a square artboard. +#[test] +fn the_artboard_tools_menu_leaves_out_what_cannot_run() { + let shown = |app: &VectorcraftApp, label: &str| menu_labels(&crate::menus::context_items(app)).contains(&label); + let mut app = menu_app(1); + for label in ["Cut", "Delete Artboards", "Delete Empty Artboards", "Select All Artboards", "Rearrange", "Fit to Artwork Bounds"] { + assert!(!shown(&app, label), "{label} with one empty artboard"); + } + assert!(shown(&app, "Copy") && shown(&app, "Switch Orientation")); + app.session.execute("artboard.setProps", &json!({"index": 0, "width": 50})).unwrap(); + assert!(!shown(&app, "Switch Orientation"), "a square artboard"); + + let mut app = menu_app(2); + app.session.set_tool_option("selected", &json!([0, 1])); + assert!(!shown(&app, "Cut"), "every artboard selected"); + assert!(!shown(&app, "Select All Artboards")); + app.session.set_tool_option("selected", &json!([1])); + app.session.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + for label in ["Cut", "Delete Artboards", "Switch Orientation"] { + assert!(!shown(&app, label), "{label} on a locked artboard"); + } + assert!(shown(&app, "Rename"), "a locked artboard can be renamed"); +} + +/// With no selection of its own the tool has its active artboard selected, and the menu acts on it. +#[test] +fn the_artboard_tools_menu_acts_on_the_active_artboard_without_a_selection() { + let mut app = menu_app(3); + select(&mut app, 1); + choose(&mut app, "Switch Orientation"); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.artboards[1].rect.width(), doc.artboards[1].rect.height()), (50.0, 100.0)); + assert_eq!(doc.artboards[0].rect.width(), 100.0); +} + +/// Each entry runs its command on the selected artboards, in one undo step, and the view follows. +#[test] +fn the_artboard_tools_menu_entries_run_on_the_selected_artboards() { + let mut app = menu_app(4); + app.session.set_tool_option("selected", &json!([2, 1])); + app.view_mut().unwrap().artboard = 2; + + let steps = undo_steps(&app); + choose(&mut app, "Switch Orientation"); + let sizes = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect::>(); + assert_eq!(sizes(&app), [100.0, 50.0, 50.0, 100.0]); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + + choose(&mut app, "Duplicate Artboards"); + assert_eq!(board_names(&app).len(), 6); + assert_eq!(undo_steps(&app), steps + 2, "one undo step"); + assert_eq!(app.session.tool_options()["selected"], json!([5, 4]), "the copies are selected, artboard 3's copy active"); + assert_eq!(app.view().unwrap().artboard, 5); + + choose(&mut app, "Delete Artboards"); + assert_eq!(board_names(&app).len(), 4); + assert_eq!(undo_steps(&app), steps + 3, "one undo step"); + assert_eq!(selected(&app, 4), 3, "the artboard before the first one deleted is active"); + assert_eq!(app.session.tool_options()["selected"], json!([3]), "and the only one selected"); + + choose(&mut app, "New Artboard"); + assert_eq!(board_names(&app).len(), 5); + assert_eq!(selected(&app, 5), 4, "the new artboard is active"); + assert_eq!(app.session.tool_options()["selected"], json!([4]), "and the only one selected"); + + choose(&mut app, "Select All Artboards"); + assert_eq!(app.session.tool_options()["selected"], json!([4, 0, 1, 2, 3])); + assert_eq!(undo_steps(&app), steps + 4, "selecting records no undo step"); + assert_eq!(selected(&app, 5), 4, "the active artboard stays active"); +} + +/// Cut and Copy from the menu take the selected artboards, in one undo step for the cut. +#[test] +fn the_artboard_tools_menu_cuts_and_copies_the_selected_artboards() { + let mut app = menu_app(4); + app.session.set_tool_option("selected", &json!([1, 2])); + app.view_mut().unwrap().artboard = 1; + choose(&mut app, "Copy"); + assert_eq!(board_names(&app).len(), 4); + assert_eq!(app.session.clipboard.artboards.len(), 2); + let steps = undo_steps(&app); + choose(&mut app, "Cut"); + assert_eq!(board_names(&app), ["Artboard 1", "Artboard 4"]); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); +} + +/// The menu's Cut and Copy record which artboards they took, so a replay in a new session, where +/// the Artboard tool isn't chosen, acts on the same artboards. +#[test] +fn the_artboard_tools_menu_replays_its_cut_and_copy_in_a_new_session() { + let mut app = menu_app(4); + app.session.set_tool_option("selected", &json!([1, 2])); + app.view_mut().unwrap().artboard = 1; + choose(&mut app, "Copy"); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!((cmd.as_str(), &p["selectedArtboards"]), ("artboard.copy", &json!([1, 2]))); + choose(&mut app, "Cut"); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!((cmd.as_str(), &p["selectedArtboards"]), ("artboard.cut", &json!([1, 2]))); + let mut replay = Session::new(); + for (id, p) in &app.session.journal { + replay.execute(id, p).unwrap(); + } + let names: Vec = replay.active().unwrap().doc.artboards.iter().map(|a| a.name.clone()).collect(); + assert_eq!(names, ["Artboard 1", "Artboard 4"]); + assert_eq!(replay.clipboard.artboards.len(), 2); +} + +/// The menu's Cut and Copy publish the copied art to the system clipboard, as Edit › Cut and Copy do. +#[test] +fn the_artboard_tools_menu_publishes_its_cut_and_copy() { + let mut app = menu_app(2); + let on = app.session.active().unwrap().doc.artboards[1].rect; + app.session.execute("shape.rectangle", &json!({"x": on.x0 + 10.0, "y": on.y0 + 10.0, "width": 20, "height": 20})).unwrap(); + app.session.set_tool_option("selected", &json!([1])); + app.view_mut().unwrap().artboard = 1; + for label in ["Copy", "Cut"] { + app.clipboard_out = None; + choose(&mut app, label); + assert!(app.clipboard_out.as_deref().is_some_and(|svg| svg.contains(">(); + let before = rects(&app); + let on = |r: vectorcraft_geom::Rect| json!({"x": r.x0 + 10.0, "y": r.y0 + 10.0, "width": 20, "height": 20}); + app.session.execute("shape.rectangle", &on(before[0])).unwrap(); + app.session.execute("shape.rectangle", &on(before[1])).unwrap(); + app.session.set_tool_option("selected", &json!([1])); + app.view_mut().unwrap().artboard = 1; + let steps = undo_steps(&app); + choose(&mut app, "Fit to Artwork Bounds"); + let fitted = rects(&app); + assert_eq!(fitted[0], before[0], "the artboard that isn't selected stays"); + assert_eq!((fitted[1].width(), fitted[1].height()), (21.0, 21.0), "the rectangle with its 1 pt stroke"); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + choose(&mut app, "Undo"); + assert_eq!(rects(&app), before); +} + +/// Delete Empty Artboards leaves a remaining artboard active, and the dialog entries open their +/// dialogs: Rename, Rearrange, Artboard Options and Export. +#[test] +fn the_artboard_tools_menu_deletes_empty_artboards_and_opens_dialogs() { + let mut app = menu_app(3); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + for (label, kind) in [ + ("Rename", crate::dialogs::rename_artboards::KIND), + ("Rearrange", crate::dialogs::rearrange_artboards::KIND), + ("Artboard Options", "artboardOptions"), + ("Export", crate::dialogs::EXPORT_FOR_SCREENS), + ] { + choose(&mut app, label); + assert_eq!(app.ui.dialog.take().map(|d| d.kind).as_deref(), Some(kind), "{label}"); + } + select(&mut app, 2); + choose(&mut app, "Delete Empty Artboards"); + assert_eq!(board_names(&app), ["Artboard 1"]); + assert_eq!(selected(&app, 1), 0); +} + +/// A lock entry lists only the artboards it changes and is left out when it has none, so it never +/// records an undo step that changes nothing. +#[test] +fn the_artboard_tools_lock_entries_have_something_to_change() { + use crate::menus::Item; + let mut app = menu_app(2); + app.session.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + let items = crate::menus::context_items(&app); + let subs = |menu: &str| match items.iter().find(|i| matches!(i, Item::Sub(t, _) if *t == menu)) { + Some(Item::Sub(_, subs)) => menu_labels(subs), + _ => vec![], + }; + assert_eq!(subs("Lock Artboards"), ["Lock All Except Selected", "Lock All"], "the selected artboard is locked already"); + assert_eq!(subs("Unlock Artboards"), ["Unlock Selected", "Unlock All"]); +} + +/// A right-click on an artboard that isn't selected selects it alone; on a selected one the selection +/// stays; off every artboard it stays too. +#[test] +fn a_right_click_with_the_artboard_tool_selects_the_artboard_under_the_pointer() { + use vectorcraft_geom::Point; + let mut app = menu_app(3); + let doc = &app.session.active().unwrap().doc; + let (a, c) = (doc.artboards[0].rect.center(), doc.artboards[2].rect.center()); + app.session.set_tool_option("selected", &json!([0, 1])); + right_click(&mut app, a); + assert_eq!(app.session.tool_options()["selected"], json!([0, 1]), "a selected artboard keeps the selection"); + right_click(&mut app, Point::new(-500.0, -500.0)); + assert_eq!(app.session.tool_options()["selected"], json!([0, 1]), "off every artboard"); + right_click(&mut app, c); + assert_eq!(app.session.tool_options()["selected"], json!([2])); + assert_eq!(app.view().unwrap().artboard, 2); +} + +/// Paste from the Artboard tool's menu centers the copied artboards on the right-click's point. With +/// no point it puts them right of the last artboard. +#[test] +fn the_artboard_tools_paste_goes_to_the_right_click() { + use vectorcraft_geom::Point; + let mut app = menu_app(2); + app.run("edit.copy", json!({})).unwrap(); + right_click(&mut app, Point::new(-300.0, 700.0)); + choose(&mut app, "Paste"); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.artboards.len(), 3); + let at = doc.artboards[2].rect.center(); + assert_eq!((at.x, at.y), (-300.0, 700.0)); + app.ui.context_artboards = None; + choose(&mut app, "Paste"); + let doc = &app.session.active().unwrap().doc; + let right = doc.artboards.iter().take(3).map(|a| a.rect.x1).fold(f64::MIN, f64::max); + assert_eq!(doc.artboards[3].rect.x0, right + 20.0); +} + +/// While the menu is open it reads what the right-click measured and does not walk the art again: +/// what it measured stands until the document or the selection changes. +#[test] +fn the_open_menu_reads_what_the_right_click_measured() { + use vectorcraft_geom::Point; + let mut app = menu_app(2); + right_click(&mut app, Point::new(10.0, 10.0)); + let facts = app.ui.context_artboards.as_mut().unwrap(); + assert!(facts.any_empty && !facts.any_art); + // Facts that differ from the document show which one the menu reads. + facts.any_empty = false; + facts.any_art = true; + let labels = menu_labels(&crate::menus::context_items(&app)); + assert!(!labels.contains(&"Delete Empty Artboards") && labels.contains(&"Fit to Artwork Bounds"), "{labels:?}"); + // An edit makes them out of date, and the menu measures again. + app.session.execute("artboard.setProps", &json!({"index": 1, "name": "Other"})).unwrap(); + let labels = menu_labels(&crate::menus::context_items(&app)); + assert!(labels.contains(&"Delete Empty Artboards") && !labels.contains(&"Fit to Artwork Bounds"), "{labels:?}"); +} + +/// `artboard.selectAll` chooses the Artboard tool first when another tool is in use, and fails +/// without a document. +#[test] +fn select_all_artboards_chooses_the_tool_and_needs_a_document() { + let mut app = menu_app(2); + app.select_tool("selection"); + assert_eq!(app.run("artboard.selectAll", json!({})).unwrap()["selected"], json!([0, 1])); + assert_eq!(app.session.tool_id(), "artboard"); + let mut empty = VectorcraftApp::new(Session::new(), Default::default()); + assert_eq!(empty.run("artboard.selectAll", json!({})).unwrap_err(), "no document"); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7f562f057..14cd10d28 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -397,6 +397,10 @@ pub struct UiState { /// is open. Not saved. #[serde(skip)] pub context_intertwine: Option<(u64, vectorcraft_doc::NodeId, vectorcraft_geom::Point, vectorcraft_doc::NodeId)>, + /// What the last right-click on the canvas with the Artboard tool measured, for the context menu + /// while it is open ([`crate::panels::artboards::MenuFacts`]). Not saved. + #[serde(skip)] + pub context_artboards: Option, /// Which link statuses the active document's images and placed documents have, for Select › /// Linked, Embedded, Missing and Modified to be on only when some do (refreshed by /// [`crate::addons::select::link_status::refresh`]). Not saved. @@ -965,6 +969,7 @@ impl Default for UiState { shape_modes_compound: false, context_locked: None, context_intertwine: None, + context_artboards: None, link_statuses: Default::default(), slot_tool: Default::default(), floating_flyouts: vec![], diff --git a/crates/ui-egui/src/tests/contextmenu.rs b/crates/ui-egui/src/tests/contextmenu.rs index e4445ca34..6dadd856c 100644 --- a/crates/ui-egui/src/tests/contextmenu.rs +++ b/crates/ui-egui/src/tests/contextmenu.rs @@ -371,10 +371,13 @@ fn every_context_label_is_a_menu_string() { app.ui.context_locked = Some((app.session.active().unwrap().uid, vec![a])); shown.extend(labels(&menus::context_items(&app))); // The Lock Artboards and Unlock Artboards submenus of the Artboard tool. - app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 80})).unwrap(); app.run("artboard.setProps", json!({"index": 0, "locked": true})).unwrap(); app.select_tool("artboard"); shown.extend(labels(&menus::context_items(&app))); + // Lock Selected with the artboard that isn't locked active. + crate::panels::artboards::select(&mut app, 1); + shown.extend(labels(&menus::context_items(&app))); let missing: Vec<_> = shown.iter().filter(|l| !strings.contains(**l)).collect(); assert!(missing.is_empty(), "context menu labels missing from menu_strings: {missing:?}"); for label in menus::CONTEXT_LABELS { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a0e392cd6..a25f189b0 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -94,7 +94,7 @@ the two can still win that race. | `document.inspect` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | layer tree, selection, the key object (`key`, null without one; a key ruler guide as `keyGuide`), history, paint defaults; `depth` / `childLimit` slice the layer tree as `document.node {summary: true}` does; `ids`, `kind`, `limit` and `offset` list just some nodes as `nodes` (with `total`) instead of the whole tree, see [mcp.md](mcp.md). Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | | `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | -| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | +| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. With the Artboard tool it lists the artboard entries (see `docs/mcp.md`), after selecting the artboard there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | | `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool`. With the Shape Builder or the Live Paint Bucket and a selection of more than 128 segments, the app finds the regions off the UI thread, so a `move` replies before the region under the pointer is highlighted, and the highlight shows in the first frame after the regions are found | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input | @@ -645,7 +645,7 @@ dialog, a drop) opens the `textImport` dialog (fields `platform`: `windows`/`mac `removeLineReturns`, `removeParagraphReturns`, `replaceSpaces`, `spaces`). `ui.dialog.confirm` places the file as area type with those options (`file.place {text: {…}}`). -System clipboard formats: through the desktop app, `edit.copy` and `edit.cut` publish what `clipboard.flavours` +System clipboard formats: through the desktop app, `edit.copy`, `edit.cut`, `artboard.copy` and `artboard.cut` publish what `clipboard.flavours` lists to the system clipboard (once they are written, the status bar shows the first of the PNG's and PDF's `warnings`, such as an image that couldn't be decoded), and every Paste command first loads what another app copied (`clipboard.importSvg`, `importPdf`, `importEps`, `importText` or `importImage`, centred in the view; the desktop app reads PDF and EPS from diff --git a/docs/gaps.md b/docs/gaps.md index 88ddb0ead..4dac9431e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -436,7 +436,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done (2026-10-29, #4522):** the Pathfinder panel menu's Make, Release and Expand Compound Shape, which were dimmed placeholders, and the panel's Expand button act on live compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html)): Alt-clicking a Shape Mode makes one in that mode, a Shape Mode sets the mode of the members selected, and the compound shape draws, exports and saves as the area its members' modes make (`object.compoundShape.*`, `crates/engine/src/tests/compound_shape.rs`). Left: blends, envelopes and warps as members, question marks on the buttons for mixed member modes, clicks that hit the area rather than the members. Estimate unchanged. - **Done (2026-10-25, #3426):** Pathfinder panel › Pathfinder Options… ([Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): Precision (0.001–10 pt; results are refit to within it and pieces thinner than it are dropped), Remove Redundant Points and Divide and Outline Will Remove Unpainted Artwork (pieces of objects with no fill), kept in the preferences (`object.pathfinder.options`, group `pathfinder`) and used by every Shape Mode and Pathfinder button and Repeat; the page gives no defaults, so ours (0.01 pt, both off) keep the earlier results byte for byte. Trap is left as it was. Estimate unchanged. -- **Done (2026-10-25):** Artboards panel › Delete Empty Artboards ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html), [artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html)): `artboard.deleteEmpty` deletes every unlocked artboard with no artwork, with its guides, in one undo step, and keeps artboard 1 when every artboard is empty and none is locked; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). The canvas context menu doesn't list it yet. Estimate unchanged. +- **Done (2026-10-25):** Artboards panel › Delete Empty Artboards ([Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html), [artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html)): `artboard.deleteEmpty` deletes every unlocked artboard with no artwork, with its guides, in one undo step, and keeps artboard 1 when every artboard is empty and none is locked; an artboard is empty when no object (hidden, locked and template art included; guides not) has visual bounds that meet or touch it, a group counting by its members and a clip group or clipping layer by its members inside the clip (our reading of "no artwork"; the pages give no finer rule). The Artboard tool's canvas context menu lists it too. Estimate unchanged. - **Done (2026-10-25):** the Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose what the panel's fields, flips and menu flips and the Properties panel's Transform section transform: the object with its pattern tiles left as they are, the tiles alone, or both; Use Registration diff --git a/docs/mcp.md b/docs/mcp.md index 6e801ee2e..e9e0fd88b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3398,7 +3398,8 @@ the art on that artboard, in one undo step. The art is what an export draws, mea effects on paths, compound paths and symbol instances. An object counts for every artboard its bounds overlap, also when it reaches past the artboard's edge, and a group or a clipping layer counts whole. An artboard with no art on it, or a locked one, keeps its size and place. The call fails with `the artboard is locked` when every artboard it names is -locked, and with `no artwork` when none of the others has art. +locked, and with `no artwork` when none of the others has art. When every artboard already fits its art, no undo step +is recorded. `artboard.fitToSelection {index?}` (Object › Artboards › Fit to Selected Art) fits artboard `index` to the selected art. Both commands take 0-based indexes and resize the first artboard when the call names none. The desktop app adds the selected artboards (those the Artboard tool has selected, else the active one) as `indices` to the @@ -3941,7 +3942,8 @@ Then `edit.paste` adds a copy of each artboard, together right of the last one a in the layers it came from, in one undo step (`edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack` put them where they were; `edit.pasteOnAllArtboards` pastes only the art), in this document or another; the result's `artboards` lists the new artboards' indices and `artboard` is the first of them, and the Artboard tool, when chosen, -selects them. `artboard.duplicate {index? | indices?, art?}` does the same in one step → `{index, indices, ids}`. +selects them. `edit.paste {artboardsAt: [x, y]}` instead centers the copied artboards on that point, as they lay, and a +value that is not `[x, y]` fails with `` `artboardsAt` must be [x, y] ``. `artboard.duplicate {index? | indices?, art?}` does the same in one step → `{index, indices, ids}`. Window › Artboards › Duplicate Artboards passes the selected artboards (those the Artboard tool has selected, else the active one) as `indices`, and a row dragged onto New Artboard passes its artboard as `index`. `artboard.move {copy: true, moveArt: true}` is the Artboard tool's Alt-drag. `artboard.duplicate` and `artboard.move` @@ -4002,6 +4004,52 @@ it; a group counts by its members, a clip group or clipping layer by its members artboard left, artboard 1 can go). With nothing to delete the command is disabled and fails with `no empty artboards to delete`. +Rename Artboards: `artboard.rename {index? | indices?, name}` renames artboards in one undo step, by default those the +Artboard tool has selected, else its active one. One artboard takes `name`, trimmed. Several take it numbered in artboard +order, `name-1`, `name-2` and so on. A locked artboard is renamed too. An empty name fails with +`` `name` must not be empty ``, a name that is not a string fails with `` `name` must be a string ``, and an artboard +that is not in the document fails with `no such artboard`. When every artboard already has its new name, the command +records no undo step → `{renamed: [index…]}`. With no params, a menu click opens the `renameArtboards` dialog (fields +`name` and `indices`), and OK with an empty name changes nothing. The Artboards panel's name field runs this command too. + +Switch Orientation: `artboard.switchOrientation {index? | indices?, scaleArt?, patterns?, lockedAndHidden?}` swaps the width and height of each +artboard in one undo step, by default those the Artboard tool has selected, else its active one. Each top-left corner +stays put, as with the Properties panel's Portrait and Landscape buttons. This rule for several artboards is VectorCraft's +own. A square artboard and a locked one stay as they are, and the call fails with `the artboard is locked` when every +artboard it names is locked. When no artboard changes, the command records no undo step → `{switched: [index…]}`. +`scaleArt` scales the art with each artboard and defaults to the Artboard tool's Scale Artwork with Artboard option. +`patterns` and `lockedAndHidden` work as they do on `artboard.setProps`. + +When a call to `artboard.duplicate`, `artboard.copy`, `artboard.cut`, `artboard.rename` or `artboard.switchOrientation` +names no artboard, its journal entry records the artboards it acted on as `selectedArtboards`. A replay of the journal +acts on those artboards, and a recorded action leaves the value out and acts on the selection when it plays. + +`artboard.selectAll {}` chooses the Artboard tool when another tool is in use and selects every artboard. The active +artboard stays active, the art's selection stays as it is, and no undo step is recorded → `{selected: [index…]}`, the +active artboard first. + +The canvas context menu with the Artboard tool lists the entries of the public +[Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) +page in the page's order, and no entries for objects or the view: Cut (`artboard.cut`), Copy (`artboard.copy`), Paste +(`edit.paste`), Undo, Redo, New Artboard (`artboard.new`), Duplicate Artboards (`artboard.duplicate`), Rename +(`artboard.rename`), Delete Artboards (`artboard.delete` with `indices`), Delete Empty Artboards +(`artboard.deleteEmpty`), Select All Artboards (`artboard.selectAll`), Lock Artboards and Unlock Artboards +(`artboard.setProps` with `locked`), Rearrange (`artboard.rearrange`), Fit to Artwork Bounds (`artboard.fitToArt`), +Switch Orientation (`artboard.switchOrientation`), Artboard Options (`tool.options {tool: "artboard"}`) and Export +(`file.exportForScreens`). Exit Isolation Mode and the Unlock submenu for locked objects under the pointer follow them. +Cut, Copy, Duplicate Artboards, Rename, Delete Artboards, Lock Selected, Unlock Selected, Fit to Artwork Bounds, Switch +Orientation and Export act on the artboards the tool has selected, else the active one. Artboard Options opens on the +active artboard. New Artboard, Paste, Undo, Redo, Delete Empty Artboards, Select All Artboards and Rearrange do not act on +the selected artboards. Lock All and Unlock All act on every artboard, and Lock All Except Selected acts on the artboards +that are not selected. A right-click on an artboard that is not selected first selects it alone. An entry that cannot +run is left out: Cut with a locked artboard or every artboard selected, Delete Artboards with no artboard to delete, Select All Artboards with every artboard selected, Rearrange with +one artboard, Fit to Artwork Bounds with no art on a selected unlocked artboard, Switch Orientation with no selected +artboard to change, and a lock entry with no artboard to change. The menu measures the art for Delete Empty Artboards and +Fit to Artwork Bounds once, at the right-click. Paste centers copied artboards on the right-click's point +(`edit.paste {artboardsAt: [x, y]}`) and centers copied objects there too (`center`). Chosen from a menu or the command +palette with the tool in use, `artboard.new` makes the new artboard active, `artboard.duplicate` selects the copies, +and `artboard.delete` and `artboard.deleteEmpty` make a remaining artboard active. + Locked artboards: `artboard.setProps {index, locked: true}` locks an artboard and `{index, locked: false}` unlocks it, each in one undo step. The Artboards panel's lock column, left of a row's options icon, shows a lock on a locked artboard, and a click there runs this command. A locked artboard keeps its place and size. `artboard.setProps` with a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 84374dbcc..95b9d6e9e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -923,6 +923,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Object › Artboards › Fit to Selected Art resizes the active artboard (`artboard.fitToSelection {index}`). It used to resize artboard 1 whichever artboard was active. When an action plays, its Fit to Artwork Bounds and Fit to Selected Art steps that name no artboard resize the artboard that is active then. Object › Artboards › Fit to Artwork Bounds and the Artboards panel menu's Fit to Artwork Bounds fit each selected artboard (those the Artboard tool has selected, else the active one) to the art on it, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes (`artboard.fitToArt {indices}`). An object partly on an artboard counts for each artboard it overlaps, and an artboard with no art on it keeps its size and place. They used to fit the active artboard to all the art in the document. The Artboards panel menu's Duplicate Artboards copies each selected artboard (those the Artboard tool has selected, else the active one) with its art, in one undo step, and the copies are then selected, as the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page describes. With the Artboard tool, Copy and Cut take every selected artboard with its art, as the public [Cut, copy, and paste artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/cut-copy-and-paste-artboards.html) page describes, and Paste brings them all back in one undo step (`artboard.duplicate`, `artboard.copy` and `artboard.cut` with `indices`). The copies keep their places relative to each other, together right of the last artboard. All three used to act on the active artboard only. + With the Artboard tool, the canvas context menu lists the entries of the public [Use the artboard context menu](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/use-artboard-context-menu.html) page in its order: Cut, Copy, Paste at the pointer, Undo, Redo, New Artboard, Duplicate Artboards, Rename (`artboard.rename`), Delete Artboards, Delete Empty Artboards, Select All Artboards (`artboard.selectAll`), Lock Artboards and Unlock Artboards, Rearrange, Fit to Artwork Bounds, Switch Orientation (`artboard.switchOrientation`), Artboard Options and Export. A right-click on an artboard that is not selected selects it first. The Artboards panel menu's New Artboard adds the artboard in one undo step and makes it the active one, as the panel's button, the Control bar and Properties do; with the Artboard tool it is then the only one selected. This is VectorCraft's own rule. The menu item used to leave the active artboard where it was. With the Artboard tool, Paste and Paste in Place make the first pasted artboard the active one (the navigator's and the Artboards panel's), and Cut makes the artboard before the first cut one active (the new first artboard when artboard 1 was cut). A command that chooses another document (`document.activate`) starts the tool on that document's active artboard, as choosing its tab does, and File › Revert keeps the active artboard and starts the tool on it. Paste used to leave the active artboard where it was while the tool had the pasted artboard active, and Cut left the active artboard on the artboard after the cut one while the tool had the one before it. With the Artboard tool, an action that reverts the document or switches to another document and back (`command.batch`), and the Actions panel menu's Batch… over a folder whose files it closes or over data sets with a Revert step (`action.batch`), keep the active artboard and start the tool on it, as File › Revert does. They used to make artboard 1 the active one.