mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
A document keeps up to 25 saved views, but the View menu listed only the
first 10, so views 11 to 25 could be saved and never chosen. All 25 are
listed now, by name, and choosing one changes the current window to its
zoom, centre and rotation (within the canvas's zoom range).
Edit Views… used to open the generic parameter dialog on the first view,
where the name of the view to change had to be typed. It now opens a list
of the document's views, like Edit Selection…: pick one to rename it or
delete it; OK applies every change as one undo step and Cancel leaves the
views as they were. view.saved.edit takes {edits: [{name, newName?,
delete?}…]} for that, the one-view form still works, and the command is
off while the document has no saved view.
A file's views are untrusted: on open at most 25 are kept, names are
trimmed, capped at 255 characters and unique, and views without a finite
centre and a positive zoom are dropped. New View… starts from the first
free "View N" and caps names the same way.
Behaviour from the public help page "Use multiple windows and views":
https://helpx.adobe.com/illustrator/using/viewing-artwork.html
docs/gaps.md (G7) and docs/target-app-parity.md take New View/Edit Views
off the missing list; docs/mcp.md documents the view.saved commands; the
catalogs get the Saved View 11-25 rows in the pattern of 1-10.
270 lines
12 KiB
Rust
270 lines
12 KiB
Rust
//! View → New View… / Edit Views…: saved views stored in the document; per-document view
|
|
//! toggles (Show Transparency Grid).
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::SavedView;
|
|
use vectorcraft_geom::Point;
|
|
|
|
use super::*;
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"view.saved.new",
|
|
"New View…",
|
|
["View"],
|
|
None,
|
|
"{name?, center: [x, y], zoom: (1 = 100%), rotation?: deg} save a view in the document (the app fills in the current one; default \"View N\"; at most 25, names up to 255 characters, each name once) → {name, index}",
|
|
has_doc,
|
|
new_view
|
|
),
|
|
cmd!(
|
|
"view.saved.edit",
|
|
"Edit Views…",
|
|
["View"],
|
|
None,
|
|
"{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
|
|
has_views,
|
|
edit_views
|
|
),
|
|
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views),
|
|
cmd!(
|
|
query "view.transparencyGrid",
|
|
"Show Transparency Grid",
|
|
["View"],
|
|
Some("Cmd+Shift+D"),
|
|
"{on?: bool (default: toggle)} show the transparency grid behind the active document's artboards (each document has its own setting) → {on}",
|
|
has_doc,
|
|
transparency_grid
|
|
),
|
|
]
|
|
}
|
|
|
|
fn transparency_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let st = s.doc_mut()?;
|
|
st.transparency_grid = p.get("on").and_then(Value::as_bool).unwrap_or(!st.transparency_grid);
|
|
Ok(json!({ "on": st.transparency_grid }))
|
|
}
|
|
|
|
fn list_views(s: &mut Session, _: &Value) -> Result<Value> {
|
|
Ok(json!({ "views": s.doc()?.doc.views }))
|
|
}
|
|
|
|
fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "view.saved.new";
|
|
let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?;
|
|
let zoom = f64_req(p, "zoom", C)?;
|
|
if !(zoom.is_finite() && zoom > 0.0) {
|
|
return Err(bad(C, "zoom must be positive"));
|
|
}
|
|
// Given, the rotation is a number: nothing malformed falls back to 0°.
|
|
let rotation = match p.get("rotation") {
|
|
Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?,
|
|
None => 0.0,
|
|
};
|
|
let st = s.doc()?;
|
|
if st.doc.views.len() >= SavedView::MAX {
|
|
return Err(bad(C, format!("a document keeps at most {} views", SavedView::MAX)));
|
|
}
|
|
let name = match str_param(p, "name").and_then(SavedView::clean_name) {
|
|
Some(n) => n,
|
|
None => SavedView::default_name(&st.doc.views),
|
|
};
|
|
if st.doc.views.iter().any(|v| v.name == name) {
|
|
return Err(bad(C, format!("a view named `{name}` exists")));
|
|
}
|
|
let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation };
|
|
let index = s.edit("New View", |d, _| {
|
|
d.views.push(view);
|
|
Ok(d.views.len() - 1)
|
|
})?;
|
|
Ok(json!({ "name": name, "index": index }))
|
|
}
|
|
|
|
/// Edit Views… needs a document with at least one saved view.
|
|
fn has_views(s: &Session) -> std::result::Result<(), String> {
|
|
let st = s.active().ok_or("no document open")?;
|
|
if st.doc.views.is_empty() { Err("no saved views".into()) } else { Ok(()) }
|
|
}
|
|
|
|
fn edit_views(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const C: &str = "view.saved.edit";
|
|
let (edits, one) = match p.get("edits").and_then(Value::as_array) {
|
|
Some(list) => (list.as_slice(), false),
|
|
None if str_param(p, "name").is_some() => (std::slice::from_ref(p), true),
|
|
None => return list_views(s, p),
|
|
};
|
|
let before = s.doc()?.doc.views.clone();
|
|
// Edits name the views as they are now, so they don't depend on each other's order.
|
|
let mut after: Vec<Option<SavedView>> = before.iter().cloned().map(Some).collect();
|
|
for e in edits {
|
|
let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?;
|
|
let i = before.iter().position(|v| v.name == name).ok_or_else(|| bad(C, format!("no view named `{name}`")))?;
|
|
let Some(slot) = after.get_mut(i) else { continue };
|
|
if bool_or(e, "delete", false) {
|
|
*slot = None;
|
|
} else if let Some(v) = slot {
|
|
v.name = str_param(e, "newName").and_then(SavedView::clean_name).ok_or_else(|| bad(C, "give newName or delete"))?;
|
|
}
|
|
}
|
|
let after: Vec<SavedView> = after.into_iter().flatten().collect();
|
|
let mut names = std::collections::HashSet::with_capacity(after.len());
|
|
for v in &after {
|
|
if !names.insert(v.name.as_str()) {
|
|
return Err(bad(C, format!("a view named `{}` exists", v.name)));
|
|
}
|
|
}
|
|
// One rename or delete keeps the name of its step; several are one Edit Views step.
|
|
let deleted = after.len() < before.len();
|
|
let label = if !one {
|
|
"Edit Views"
|
|
} else if deleted {
|
|
"Delete View"
|
|
} else {
|
|
"Rename View"
|
|
};
|
|
let result = match (one, deleted) {
|
|
(true, true) => json!({ "deleted": str_param(p, "name") }),
|
|
(true, false) => json!({ "name": str_param(p, "newName").and_then(SavedView::clean_name) }),
|
|
(false, _) => json!({ "views": after }),
|
|
};
|
|
if before != after {
|
|
s.edit(label, |d, _| {
|
|
d.views = after;
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
/// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport.
|
|
#[test]
|
|
fn saved_views_require_exact_finite_coordinates_and_rotation() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({})).unwrap();
|
|
let before = s.doc().unwrap().history.undo.len();
|
|
for params in [
|
|
json!({"center": [10], "zoom": 2}),
|
|
json!({"center": [10, 20, 30], "zoom": 2}),
|
|
json!({"center": [10, "bad"], "zoom": 2}),
|
|
json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}),
|
|
json!({"center": [10, 20], "zoom": 2, "rotation": null}),
|
|
] {
|
|
assert!(s.execute("view.saved.new", ¶ms).is_err(), "accepted {params}");
|
|
assert!(s.doc().unwrap().doc.views.is_empty());
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), before);
|
|
}
|
|
s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap();
|
|
let views = &s.doc().unwrap().doc.views;
|
|
assert_eq!(views.len(), 1);
|
|
assert_eq!(views[0].rotation, -15.0);
|
|
}
|
|
|
|
#[test]
|
|
fn save_rename_delete_views() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({})).unwrap();
|
|
let v = s.execute("view.saved.new", &json!({"center": [100, 200], "zoom": 2.0})).unwrap();
|
|
assert_eq!(v["name"], json!("View 1"));
|
|
s.execute("view.saved.new", &json!({"name": "Logo", "center": [10, 20], "zoom": 4.0, "rotation": 15})).unwrap();
|
|
assert!(s.execute("view.saved.new", &json!({"name": "Logo", "center": [0, 0], "zoom": 1.0})).is_err());
|
|
assert!(s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 0})).is_err());
|
|
s.execute("view.saved.edit", &json!({"name": "View 1", "newName": "Overview"})).unwrap();
|
|
let l = s.execute("view.saved.list", &json!({})).unwrap();
|
|
assert_eq!(l["views"][0]["name"], json!("Overview"));
|
|
assert_eq!(l["views"][1]["rotation"], json!(15.0));
|
|
s.execute("view.saved.edit", &json!({"name": "Logo", "delete": true})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc.views.len(), 1);
|
|
// Views travel with the file.
|
|
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
|
assert_eq!(vectorcraft_format::load(&bytes).unwrap().views[0].name, "Overview");
|
|
}
|
|
|
|
fn names(s: &mut Session) -> Vec<String> {
|
|
s.doc().unwrap().doc.views.iter().map(|v| v.name.clone()).collect()
|
|
}
|
|
|
|
fn session_with_views(names: &[&str]) -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({})).unwrap();
|
|
for (i, n) in names.iter().enumerate() {
|
|
s.execute("view.saved.new", &json!({"name": n, "center": [i, i], "zoom": 1 + i})).unwrap();
|
|
}
|
|
s
|
|
}
|
|
|
|
/// Two named views go out with the file and come back with their names, centres, zooms and
|
|
/// rotations, in order.
|
|
#[test]
|
|
fn saved_views_reopen_with_their_names_and_viewports() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({})).unwrap();
|
|
s.execute("view.saved.new", &json!({"name": "Overview", "center": [306, 396], "zoom": 0.5})).unwrap();
|
|
s.execute("view.saved.new", &json!({"name": "Logo detail", "center": [40.25, -12.5], "zoom": 8, "rotation": -30})).unwrap();
|
|
let saved = s.doc().unwrap().doc.views.clone();
|
|
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
|
|
assert_eq!(back.views, saved);
|
|
assert_eq!((back.views[1].center.x, back.views[1].center.y, back.views[1].zoom, back.views[1].rotation), (40.25, -12.5, 8.0, -30.0));
|
|
}
|
|
|
|
/// Edit Views… applies its renames and deletes together, as one undo step, by the names the
|
|
/// views had before (so two can swap names); a shared or unknown name changes nothing.
|
|
#[test]
|
|
fn edit_views_applies_several_edits_as_one_step() {
|
|
let mut s = session_with_views(&["A", "B", "C"]);
|
|
let steps = s.doc().unwrap().history.undo.len();
|
|
let edits = json!({"edits": [
|
|
{"name": "A", "newName": "B"},
|
|
{"name": "B", "newName": " A "},
|
|
{"name": "C", "delete": true},
|
|
]});
|
|
let r = s.execute("view.saved.edit", &edits).unwrap();
|
|
assert_eq!(r["views"].as_array().map(Vec::len), Some(2));
|
|
assert_eq!(names(&mut s), ["B", "A"]);
|
|
// The views keep their viewports under their new names.
|
|
assert_eq!(s.doc().unwrap().doc.views[0].zoom, 1.0);
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(names(&mut s), ["A", "B", "C"]);
|
|
for bad in [
|
|
json!({"edits": [{"name": "A", "newName": "C"}]}),
|
|
json!({"edits": [{"name": "A", "newName": "X"}, {"name": "B", "newName": "X"}]}),
|
|
json!({"edits": [{"name": "A", "delete": true}, {"name": "Nope", "delete": true}]}),
|
|
json!({"edits": [{"name": "A", "newName": " "}]}),
|
|
] {
|
|
assert!(s.execute("view.saved.edit", &bad).is_err(), "accepted {bad}");
|
|
assert_eq!(names(&mut s), ["A", "B", "C"]);
|
|
}
|
|
}
|
|
|
|
/// A document keeps at most 25 views; unnamed ones take the first free "View N", and names are
|
|
/// capped like other names the user gives.
|
|
#[test]
|
|
fn a_document_keeps_at_most_25_views() {
|
|
let all: Vec<String> = (0..25).map(|i| format!("V{i}")).collect();
|
|
let mut s = session_with_views(&all.iter().map(String::as_str).collect::<Vec<_>>());
|
|
assert!(s.execute("view.saved.new", &json!({"name": "One too many", "center": [0, 0], "zoom": 1})).is_err());
|
|
assert_eq!(s.doc().unwrap().doc.views.len(), 25);
|
|
s.execute("view.saved.edit", &json!({"name": "V0", "delete": true})).unwrap();
|
|
assert_eq!(s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 1})).unwrap()["name"], "View 1");
|
|
s.execute("view.saved.edit", &json!({"name": "V1", "delete": true})).unwrap();
|
|
let long = "x".repeat(1000);
|
|
assert_eq!(s.execute("view.saved.new", &json!({"name": long, "center": [0, 0], "zoom": 1})).unwrap()["name"], "x".repeat(255));
|
|
}
|
|
|
|
/// Edit Views… is off until the document has a view.
|
|
#[test]
|
|
fn edit_views_needs_a_saved_view() {
|
|
let mut s = session_with_views(&[]);
|
|
assert!(s.execute("view.saved.edit", &json!({})).is_err());
|
|
s.execute("view.saved.new", &json!({"name": "Only", "center": [0, 0], "zoom": 1})).unwrap();
|
|
assert_eq!(s.execute("view.saved.edit", &json!({})).unwrap()["views"][0]["name"], "Only");
|
|
}
|
|
}
|