mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
PDF.W1: reconcile shared writer with current main
This commit is contained in:
@@ -265,7 +265,7 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
|
||||
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
|
||||
/// document.
|
||||
fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
if typing {
|
||||
|
||||
@@ -13,14 +13,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// What an error rolls back besides the documents: session-level state a step may change
|
||||
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
|
||||
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
|
||||
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
|
||||
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
|
||||
// would otherwise run inside it and take its baseline as theirs (#1697).
|
||||
if s.active().is_some() {
|
||||
super::super::edit::settle_interaction(s)?;
|
||||
}
|
||||
// The documents as they are now, whole: what a failed step brings back. Their art, history,
|
||||
// file, format and saved state share their data with the open ones until a step changes them.
|
||||
let start = s.docs.clone();
|
||||
let active = s.active;
|
||||
s.batch_stash = Some(vec![]);
|
||||
let r = run_steps(s, &label, artboard, &mut cmds);
|
||||
let stash = s.batch_stash.take().unwrap_or_default();
|
||||
let results = match r {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
roll_back(s, &start, stash);
|
||||
roll_back(s, start, &stash);
|
||||
s.active = active;
|
||||
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
|
||||
s.pending_paint = None;
|
||||
@@ -81,22 +89,17 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// The documents as they were before the batch (`start`: their uids in order): the ones it opened
|
||||
/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone.
|
||||
fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec<DocState>) {
|
||||
let mut open = std::mem::take(&mut s.docs);
|
||||
for &uid in start {
|
||||
// The newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default();
|
||||
// Its state before the batch: the first one a step closed or replaced, else the open one.
|
||||
let st = match stash.iter().position(|d| d.uid == uid) {
|
||||
Some(i) => Some(stash.remove(i)),
|
||||
None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)),
|
||||
};
|
||||
if let Some(mut st) = st {
|
||||
st.undo_interaction();
|
||||
st.revision = newest.saturating_add(1);
|
||||
s.docs.push(st);
|
||||
}
|
||||
/// The documents as they were before the batch (`start`), whatever the steps did to them: opened
|
||||
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
|
||||
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
|
||||
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
|
||||
/// written.
|
||||
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
|
||||
let open = std::mem::take(&mut s.docs);
|
||||
s.docs = start;
|
||||
for st in &mut s.docs {
|
||||
// Past the newest revision the document reached, so the restored one redraws.
|
||||
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
|
||||
st.revision = newest.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,12 +273,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
/// own): rasters stay linked, SVG files become art, missing ones placeholders (see
|
||||
/// [`vectorcraft_svg::ImportOptions`]).
|
||||
pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
let read = |path: &str| {
|
||||
// Only files (not folders or devices) are read.
|
||||
file_stamp(path)?;
|
||||
let bytes = read_file(path).ok()?;
|
||||
let link = crate::cmd::links::link_info(path, &bytes);
|
||||
Some((bytes, link))
|
||||
scope.clone().run(|| {
|
||||
// Only files (not folders or devices) are read.
|
||||
file_stamp(path)?;
|
||||
let bytes = read_file(path).ok()?;
|
||||
let link = crate::cmd::links::link_info(path, &bytes);
|
||||
Some((bytes, link))
|
||||
})
|
||||
};
|
||||
vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err)
|
||||
}
|
||||
@@ -419,7 +424,7 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
|
||||
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
|
||||
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
|
||||
pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
|
||||
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
|
||||
|
||||
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
|
||||
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
|
||||
|
||||
@@ -457,6 +457,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use vectorcraft_doc::range::parse_range;
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
|
||||
@@ -202,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Bounds",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
|
||||
has_selection,
|
||||
set_bounds
|
||||
),
|
||||
@@ -1400,6 +1400,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
if transforms_objects(p)
|
||||
&& !bool_or(p, "copy", false)
|
||||
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
|
||||
{
|
||||
return set_anchor_bounds(s, p, ids, b);
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
|
||||
// the math below runs in its frame.
|
||||
@@ -1419,15 +1426,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(v, refi);
|
||||
let mut w = f64_or(p, "width", v.width());
|
||||
let mut h = f64_or(p, "height", v.height());
|
||||
if bool_or(p, "proportional", false) {
|
||||
if p.get("width").is_some() && v.width() > 0.0 {
|
||||
h = v.height() * w / v.width();
|
||||
} else if p.get("height").is_some() && v.height() > 0.0 {
|
||||
w = v.width() * h / v.height();
|
||||
}
|
||||
}
|
||||
let (w, h) = wanted_size(p, v);
|
||||
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
|
||||
// exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the
|
||||
// scales where it misses.
|
||||
@@ -1477,6 +1476,35 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
|
||||
}
|
||||
|
||||
/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or
|
||||
/// with `proportional` follows the one given.
|
||||
fn wanted_size(p: &Value, b: Rect) -> (f64, f64) {
|
||||
let mut w = f64_or(p, "width", b.width());
|
||||
let mut h = f64_or(p, "height", b.height());
|
||||
if bool_or(p, "proportional", false) {
|
||||
if p.get("width").is_some() && b.width() > 0.0 {
|
||||
h = b.height() * w / b.width();
|
||||
} else if p.get("height").is_some() && b.height() > 0.0 {
|
||||
w = b.width() * h / b.height();
|
||||
}
|
||||
}
|
||||
(w, h)
|
||||
}
|
||||
|
||||
/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of
|
||||
/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only
|
||||
/// the anchors, their handles with them, and those objects move or scale.
|
||||
fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec<NodeId>, b: Rect) -> Result<Value> {
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(b, refi);
|
||||
let (w, h) = wanted_size(p, b);
|
||||
// A single anchor, or anchors in a line, have no size to scale on that side.
|
||||
let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 };
|
||||
let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height())));
|
||||
let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y));
|
||||
apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true)
|
||||
}
|
||||
|
||||
/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with
|
||||
/// symbol instances measured without the strokes in their art
|
||||
/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]).
|
||||
|
||||
@@ -594,7 +594,7 @@ pub(crate) fn outlined_stroke(
|
||||
st: &StrokeLayer,
|
||||
) -> Option<Node> {
|
||||
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
|
||||
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
|
||||
let mut pieces = vectorcraft_brush::stroke_pieces(d, b, &path.to_bezpath(), st);
|
||||
let art = match pieces.len() {
|
||||
0 => return None,
|
||||
1 => pieces.remove(0),
|
||||
|
||||
@@ -90,9 +90,13 @@ fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
|
||||
}
|
||||
let arrangement = po::shape_builder(&shapes, true);
|
||||
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
|
||||
// A Shaper Group's faces are what its shapes fill and what its open paths enclose: the gaps
|
||||
// closed paths enclose are the Shape Builder's only.
|
||||
let open = po::has_open_path(&shapes);
|
||||
let regions = arrangement
|
||||
.regions
|
||||
.into_iter()
|
||||
.filter(|r| open || !r.sources.is_empty())
|
||||
.filter_map(|r| {
|
||||
let bounds = r.path.bounds()?;
|
||||
let path = r.path.to_bezpath();
|
||||
|
||||
@@ -113,4 +113,38 @@ mod tests {
|
||||
s.execute("text.tabs.clear", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([]));
|
||||
}
|
||||
|
||||
/// #1877: a leader set the way the Tabs panel sets it fills the tab's gap with its dots.
|
||||
#[test]
|
||||
fn tab_leader_fills_the_gap_with_its_characters() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "AAA\t20", "size": 12})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "left", "leader": ".", "alignOn": "."}]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) else { panic!() };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let x_of = |b: usize| lay.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
|
||||
let (from, to) = (x_of(3), x_of(4));
|
||||
assert!(to - from > 150.0, "{from}..{to}");
|
||||
// Contours drawn inside the gap: only the leader's dots can be there.
|
||||
let mut dots = 0;
|
||||
for g in &lay.glyphs {
|
||||
let mut contours: Vec<kurbo::BezPath> = vec![];
|
||||
for el in g.outline.elements() {
|
||||
match (el, contours.last_mut()) {
|
||||
(kurbo::PathEl::MoveTo(_), _) | (_, None) => contours.push(kurbo::BezPath::from_vec(vec![*el])),
|
||||
(_, Some(c)) => c.push(*el),
|
||||
}
|
||||
}
|
||||
for c in contours {
|
||||
let r = kurbo::Shape::bounding_box(&c);
|
||||
if r.x0 > from - 1e-6 && r.x1 < to + 1e-6 {
|
||||
assert!(r.width() < 3.0 && r.height() < 3.0, "not a dot: {r:?}");
|
||||
dots += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(dots > 30, "{dots} dots in a {} pt gap", to - from);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,6 +821,32 @@ fn transforms_act_on_direct_selected_anchors() {
|
||||
assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
}
|
||||
|
||||
/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H
|
||||
/// (`object.setBounds`) are the box around those anchors, and only they move or scale.
|
||||
#[test]
|
||||
fn set_bounds_acts_on_direct_selected_anchors() {
|
||||
let mut s = session();
|
||||
let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true});
|
||||
let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap());
|
||||
let points = |s: &Session| -> Vec<(f64, f64)> {
|
||||
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") };
|
||||
path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect()
|
||||
};
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"x": 130, "reference": 0})).unwrap();
|
||||
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "only the selected anchor moves");
|
||||
s.execute("object.setBounds", &json!({"y": 20, "reference": 4})).unwrap();
|
||||
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
// Two anchors: W scales their box about the reference point, the others stay.
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"width": 50, "reference": 3})).unwrap();
|
||||
assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (50.0, 100.0), (0.0, 100.0)]);
|
||||
// The path selected whole moves as a whole.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("object.setBounds", &json!({"x": 10, "reference": 0})).unwrap();
|
||||
assert_eq!(points(&s), [(10.0, 0.0), (140.0, 20.0), (60.0, 100.0), (10.0, 100.0)]);
|
||||
}
|
||||
|
||||
/// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and
|
||||
/// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to
|
||||
/// Current Layer on objects already on top of the current layer.
|
||||
@@ -1480,6 +1506,26 @@ fn direct_selection_moves_one_anchor() {
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
/// #1816: with Snap to Grid on, a dragged anchor lands on the grid's subdivisions, not on whole
|
||||
/// points, even with Snap to Pixel left on too.
|
||||
#[test]
|
||||
fn direct_selection_snaps_anchors_to_the_grid() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1));
|
||||
let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9;
|
||||
let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() };
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 101.0, 101.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 131.0, 113.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 131.0, 113.0), v).unwrap();
|
||||
let p = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().clone();
|
||||
let moved = p.subpaths[0].anchors[0].p;
|
||||
assert!(on_grid(moved.x) && on_grid(moved.y), "anchor at {moved:?}, grid step {step}");
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0));
|
||||
}
|
||||
|
||||
/// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all
|
||||
/// its anchors, so toggling one deselects it alone and adding one keeps them all.
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,39 @@ fn shape_builder_regions_query() {
|
||||
assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2));
|
||||
}
|
||||
|
||||
/// #1691: four circles in a 2×2 grid, each touching its neighbours, enclose a diamond nothing
|
||||
/// fills. It is a region, and a drag from a circle into it merges the two.
|
||||
#[test]
|
||||
fn shape_builder_merges_the_gap_touching_circles_enclose() {
|
||||
let mut s = session();
|
||||
let mut circles = vec![];
|
||||
for (x, y) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)] {
|
||||
let r = s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap();
|
||||
circles.push(NodeId(r["id"].as_u64().unwrap()));
|
||||
}
|
||||
set_fill(&mut s, circles[0], Color::rgb(1.0, 0.0, 0.0));
|
||||
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
let r = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
let regs = r["regions"].as_array().unwrap();
|
||||
assert_eq!(regs.len(), 5, "{regs:?}");
|
||||
assert_eq!(regs.iter().filter(|x| x["sources"].as_array().unwrap().is_empty()).count(), 1, "the diamond");
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [100, 100]]})).unwrap();
|
||||
assert_eq!(r["regions"], 2);
|
||||
let out = ids(&r);
|
||||
assert_eq!(out.len(), 4, "the merged shape and the three other circles");
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
// A circle (its Bézier outline: 7,854.9) and the diamond between the four (2,145.1).
|
||||
assert!((area(&s, merged) - 10000.0).abs() < 10.0, "{}", area(&s, merged));
|
||||
assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the look of the circle the drag began on");
|
||||
assert_eq!(layer_children(&s), 4);
|
||||
// A click in the diamond alone makes it a shape of its own.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 100]]})).unwrap();
|
||||
assert_eq!(ids(&r).len(), 5);
|
||||
assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 2146.0).abs() < 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_tool_drives_the_command() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -314,6 +314,32 @@ fn a_copy_stays_inside_the_write_root() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// An SVG's linked files are read on the importer's own thread (it needs a deep stack): the roots
|
||||
/// in force on the thread that opens the SVG hold there too.
|
||||
#[test]
|
||||
fn an_svg_reads_its_links_within_the_read_root() {
|
||||
let root = folder("svg-roots");
|
||||
for dir in ["in", "out"] {
|
||||
write(&root.join(dir).join("dot.png"), &png(4, 4, RED));
|
||||
}
|
||||
// What came of an SVG linking to the picture in `dir`: the pictures read, and the warnings.
|
||||
let import = |dir: &str| {
|
||||
let href = root.join(dir).join("dot.png").to_string_lossy().replace('\\', "/");
|
||||
let svg =
|
||||
format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"10\" height=\"10\"><image width=\"4\" height=\"4\" href=\"{href}\"/></svg>");
|
||||
let (doc, warnings) = crate::cmd::fileio::import_svg(&svg, None).unwrap();
|
||||
(doc.images.len(), warnings)
|
||||
};
|
||||
let (inside, outside) = (import("in"), import("out"));
|
||||
assert_eq!(inside, outside, "both are read with no roots in force");
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap();
|
||||
crate::file_access::confine(roots.as_ref(), || {
|
||||
assert_eq!(import("in"), inside, "inside the read root: read as before");
|
||||
assert_ne!(import("out"), outside, "outside the read root: not read");
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_search_params_never_fail_internally() {
|
||||
let root = folder("junk");
|
||||
|
||||
@@ -150,6 +150,30 @@ fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc!");
|
||||
}
|
||||
|
||||
/// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type
|
||||
/// and keeps what was typed, and typing goes on after it.
|
||||
#[test]
|
||||
fn a_cmd_drag_while_typing_moves_the_type_and_keeps_the_typing() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("Hello", v).unwrap();
|
||||
let before = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
let (at, cmd) = (before.center(), Mods { cmd: true, ..Default::default() });
|
||||
for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 100.0), (PointerKind::Up, 100.0)] {
|
||||
s.pointer(&PointerEvent::new(kind, at.x + dx, at.y).with_mods(cmd), v).unwrap();
|
||||
}
|
||||
assert_eq!(obj(&s, id).plain_text(), "Hello", "the typing is kept");
|
||||
let moved = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
assert!(moved.x0 - before.x0 > 50.0, "the type moved: {before:?} → {moved:?}");
|
||||
s.tool_text("!", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "Hello!");
|
||||
let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
assert!((after.x0 - moved.x0).abs() < 1e-6, "and stays where it was moved: {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_during_a_drag_takes_back_only_the_drag() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -115,6 +115,12 @@ impl Session {
|
||||
return *ev;
|
||||
}
|
||||
let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
|
||||
// Typing in progress is kept as its own step first: the lent tool's drag previews from the
|
||||
// document its interaction started from, which would drop the typing (#1881).
|
||||
if crate::cmd::typing_in_progress(self) {
|
||||
// Fails only without a document, and then there is no typing to keep.
|
||||
let _ = crate::cmd::settle_interaction(self);
|
||||
}
|
||||
let lent = self.make_tool(id);
|
||||
self.lender = Some(std::mem::replace(&mut self.tool, lent));
|
||||
PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
|
||||
@@ -188,7 +194,9 @@ impl Session {
|
||||
guides: view.guides && !self.menu.guides_locked,
|
||||
snap_to_grid: view.snap_to_grid,
|
||||
show_bbox: view.show_bbox,
|
||||
snap_to_pixel: view.snap_to_pixel,
|
||||
// Snap to Grid and Snap to Pixel are exclusive, as in Illustrator; left both on, the grid
|
||||
// wins instead of pixels quietly rounding every drag to whole points (#1816).
|
||||
snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid,
|
||||
snap_to_point: view.snap_to_point,
|
||||
corner_widgets: view.corner_widgets,
|
||||
fill_active: self.fill_active,
|
||||
|
||||
Reference in New Issue
Block a user