From d6eb5261860875d6989bb12fd1c15cf505c4088c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 12:23:34 +0700 Subject: [PATCH] Fix #252: area type resizes its frame with the mouse Dragging a bounding-box handle of area type with the Selection tool now resizes the type area: the text reflows at its own size (threads too) instead of being scaled, and History says "Resize Type Area". With other objects selected they scale as before while the area type resizes its frame. Direct Selection picks the frame's corners and edges and reshapes the area ("Reshape Type Area", new command text.reshapeArea), and the selected frame's anchors and outline show. Area Type Options gains Width and Height. Point type, rotation, Object > Transform > Scale, the Scale tool and the Transform panel still transform the type. Root cause: every handle drag previewed object.transform, which folds the matrix into the text's xf (TextObject::transform), so area type scaled its glyphs; and Direct Selection's anchor hit test only looked at paths, so the frame's corners could not be picked. object.transform now takes typeAreas, which reshapes the frame in text space (xf^-1 * m * xf), and the tools use it and text.reshapeArea. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 6 +- crates/doc/src/text.rs | 90 ++++++++- crates/engine/src/cmd/object.rs | 22 ++- crates/engine/src/cmd/select.rs | 3 +- crates/engine/src/cmd/typecmd.rs | 124 ++++++++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_typearea.rs | 269 ++++++++++++++++++++++++++ crates/tools/src/direct.rs | 163 +++++++++++++--- crates/tools/src/lib.rs | 11 ++ crates/tools/src/select.rs | 59 +++++- crates/ui-egui/src/canvas.rs | 15 +- crates/ui-egui/src/dialogs/command.rs | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 14 ++ 14 files changed, 716 insertions(+), 65 deletions(-) create mode 100644 crates/engine/src/tests_typearea.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4a368da5..5f621006 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,7 +116,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). -- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. +- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. - **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). @@ -151,7 +151,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | @@ -186,7 +186,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | -| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 | +| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 | | Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | | Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 7a2ff704..8c90092a 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_geom::{Affine, PathData, Point, Rect}; +use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2}; use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer}; @@ -476,6 +476,49 @@ impl TextObject { pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } + /// Area type's frame (the type area) in document space; None for other type. + pub fn area_frame(&self) -> Option { + match &self.kind { + TextKind::Area { frame } => Some(frame.transformed(self.xf)), + _ => None, + } + } + /// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size + /// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for + /// other type, a degenerate `xf` or a frame that would not be finite. + pub fn transform_area(&mut self, a: Affine) -> bool { + let xf = self.xf; + self.reshape_area(|frame, to_text| { + frame.transform(to_text * a * xf); + true + }) + } + /// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with + /// their handles (a Direct Selection drag of a frame corner or edge). False, changing + /// nothing, when an anchor doesn't exist, or as [`Self::transform_area`]. + pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool { + self.reshape_area(|frame, to_text| { + // A distance: only the linear part of the document → text map applies. + let local = to_text * d.to_point() - to_text * Point::ZERO; + refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some()) + }) + } + /// Edit a copy of the area frame with `f` (given the document → text space map) and keep it + /// when `f` succeeds and the result is finite. + fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool { + let det = self.xf.determinant(); + if !det.is_finite() || det.abs() < 1e-12 { + return false; + } + let to_text = self.xf.inverse(); + let TextKind::Area { frame } = &mut self.kind else { return false }; + let mut next = frame.clone(); + if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) { + return false; + } + *frame = next; + true + } /// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so /// `1 / scale` keeps their weight through a transform that scales the type). pub fn scale_char_strokes(&mut self, s: f64) { @@ -505,4 +548,49 @@ mod tests { assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0); assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999); } + + /// 120 × 40 area type at (40, 40), its text drawn at twice its size. + fn area_type() -> TextObject { + let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default()); + t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) }; + t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0); + t + } + + #[test] + fn area_resizes_its_frame_and_keeps_its_type() { + let mut t = area_type(); + assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0))); + // Bottom-right handle to (200, 140), about the top-left corner. + let o = Affine::translate((40.0, 40.0)); + assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse())); + assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size"); + let b = t.bounds().unwrap(); + assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}"); + assert_eq!(t.area_frame().unwrap().bounds(), Some(b)); + // Point type has no area; a degenerate transform or a non-finite frame changes nothing. + let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default()); + assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none()); + let before = t.clone(); + assert!(!t.transform_area(Affine::scale(f64::INFINITY))); + t.xf = Affine::scale(0.0); + assert!(!t.transform_area(Affine::scale(2.0))); + t.xf = before.xf; + assert_eq!(t, before); + } + + #[test] + fn area_anchors_move_in_document_space() { + let mut t = area_type(); + // The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×. + assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0))); + let TextKind::Area { frame } = &t.kind else { panic!() }; + assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0)); + assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay"); + // A missing anchor changes nothing. + let before = t.clone(); + assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0))); + assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0))); + assert_eq!(t, before); + } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 35c5bf06..2e675eef 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Transform", [], None, - "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences)", + "{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)", has_doc, transform ), @@ -202,15 +202,18 @@ pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling { Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), keep_type_strokes: !strokes, keep_corners: !corners } } -/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]). -/// Records Transform Again. +/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]; +/// `typeAreas`: see [`resize_type_area`]). Records Transform Again. pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value) -> Result { let copy = bool_or(p, "copy", false); + let areas = bool_or(p, "typeAreas", false); let sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() }; let ids = s.edit(label, |d, sel| { let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() }; for id in &targets { - if let Some(n) = d.node_mut(*id) { + if let Some(n) = d.node_mut(*id) + && !(areas && resize_type_area(n, xf)) + { n.transform(xf, sc); } } @@ -224,6 +227,17 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text +/// reflows at its size. False for anything else (and type in perspective), which transforms as +/// usual. +fn resize_type_area(n: &mut Node, xf: Affine) -> bool { + if n.perspective.is_some() { + return false; + } + let NodeKind::Text(t) = &mut n.kind else { return false }; + super::typecmd::reshape_area_with(t, |t| t.transform_area(xf)) +} + fn transform(s: &mut Session, p: &Value) -> Result { let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; let ids = match ids_param(p, "ids") { diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 2b797d20..c906c68a 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -188,7 +188,8 @@ fn key(s: &mut Session, p: &Value) -> Result { ok() } -fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> { +/// `[[subpath, anchor]…]` anchor references (malformed entries are left out). +pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> { v.and_then(Value::as_array) .map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect()) .unwrap_or_default() diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 2d8a6924..f8d044e5 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject}; -use vectorcraft_geom::PathData; +use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{len_param, num_param, shape_node}; @@ -48,10 +48,19 @@ pub fn specs() -> Vec { "Area Type Options…", ["Type"], None, - "{ids?, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options (none given: query) → the first object's options", + "{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options", has_selection, area_options ), + cmd!( + "text.reshapeArea", + "Reshape Type Area", + [], + None, + "{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)", + has_doc, + reshape_area + ), cmd!( "text.fitHeadline", "Fit Headline", @@ -265,25 +274,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// Reshape area type's frame with `f` ([`TextObject::transform_area`], +/// [`TextObject::move_area_anchors`]) and lay its text out again. False, changing nothing, when +/// `f` fails or the frame would reach past [`crate::MAX_COORD`] (the text flows over its height). +pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObject) -> bool) -> bool { + let before = t.kind.clone(); + let within = |t: &TextObject| match &t.kind { + vectorcraft_doc::TextKind::Area { frame } => { + frame.bounds().is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD)) + } + _ => false, + }; + if !(f(t) && within(t)) { + t.kind = before; + return false; + } + refresh_bounds(t); + true +} + +/// Area type's text, if `n` is area type. +fn area_text(n: Option<&Node>) -> Option<&TextObject> { + match n.map(|n| &n.kind) { + Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t), + _ => None, + } +} + +/// The width and height of area type's frame in points, along the type's own axes, and the frame's +/// bounds in text space. +fn area_size(t: &TextObject) -> Option<(f64, f64, vectorcraft_geom::Rect)> { + let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None }; + let b = frame.bounds()?; + let [a, bb, c, d, _, _] = t.xf.as_coeffs(); + Some((b.width() * a.hypot(bb), b.height() * c.hypot(d), b)) +} + +/// Size area type's frame to `w` × `h` points (None: keep that side) from its top-left corner, +/// and lay its text out again. False when the frame doesn't change. +fn size_area(t: &mut TextObject, w: Option, h: Option) -> bool { + let Some((cw, ch, b)) = area_size(t) else { return false }; + let factor = |to: Option, cur: f64| match to { + Some(to) if cur > 1e-9 && (to - cur).abs() > 1e-9 => to / cur, + _ => 1.0, + }; + let (sx, sy) = (factor(w, cw), factor(h, ch)); + if sx == 1.0 && sy == 1.0 { + return false; + } + let o = Affine::translate(b.origin().to_vec2()); + let a = t.xf * o * Affine::scale_non_uniform(sx, sy) * o.inverse() * t.xf.inverse(); + reshape_area_with(t, |t| t.transform_area(a)) +} + fn area_options(s: &mut Session, p: &Value) -> Result { const C: &str = "text.areaOptions"; let ids: Vec = { let d = &s.doc()?.doc; - text_targets(s, p, C)? - .into_iter() - .filter(|i| matches!(d.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }))) - .collect() + text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect() }; - let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?; - let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) { - Some(NodeKind::Text(t)) => t.area.clone(), - _ => return Err(bad(C, "select area type (text in a frame)")), + let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?; + let options = |s: &Session| -> Result { + let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?; + let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?; + if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) { + o.insert("width".into(), json!(w)); + o.insert("height".into(), json!(h)); + } + Ok(v) }; - let mut v = serde_json::to_value(¤t).map_err(|e| EngineError::Other(e.to_string()))?; - let mut changed = false; + let mut v = options(s)?; + let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0)); + let (w, h) = (size("width"), size("height")); + let mut changed = w.is_some() || h.is_some(); if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) { for (k, val) in src { - if o.contains_key(k) && k != "ids" { + if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") { o.insert(k.clone(), val.clone()); changed = true; } @@ -298,17 +364,43 @@ fn area_options(s: &mut Session, p: &Value) -> Result { opts.gutter = opts.gutter.clamp(0.0, 10_000.0); opts.inset = opts.inset.clamp(0.0, 10_000.0); opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0); - let out = serde_json::to_value(&opts).map_err(|e| EngineError::Other(e.to_string()))?; s.edit("Area Type Options", |d, _| { for id in &ids { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { t.area = opts.clone(); - refresh_bounds(t); + // A resized frame has laid its text out again; otherwise the new options do here. + if !size_area(t, w, h) { + refresh_bounds(t); + } } } Ok(()) })?; - Ok(out) + options(s) +} + +fn reshape_area(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.reshapeArea"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let mut refs = super::select::parse_refs(p.get("anchors")); + refs.sort_unstable(); + refs.dedup(); + if refs.is_empty() { + return Err(bad(C, "missing anchors [[subpath, anchor]…]")); + } + let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); + let node = s.doc()?.doc.node(id); + if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) { + return Err(bad(C, "not area type (text in a frame) outside perspective")); + } + s.edit("Reshape Type Area", |doc, _| { + let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) { + return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas")); + } + Ok(()) + })?; + ok() } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 61ffdb24..d908071d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1388,6 +1388,8 @@ mod tests_toolsettings; #[cfg(test)] mod tests_transparencygrid; #[cfg(test)] +mod tests_typearea; +#[cfg(test)] mod tests_units; #[cfg(test)] mod tests_webexport; diff --git a/crates/engine/src/tests_typearea.rs b/crates/engine/src/tests_typearea.rs new file mode 100644 index 00000000..7a1da0eb --- /dev/null +++ b/crates/engine/src/tests_typearea.rs @@ -0,0 +1,269 @@ +//! Resizing area type (#252): a bounding-box handle drag and Area Type Options size the type area +//! and the text reflows at its size; Direct Selection reshapes the frame by a corner or an edge. +//! Point type still scales. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeKind, TextKind, TextObject}; +use vectorcraft_geom::{Affine, Point, Rect}; +use vectorcraft_text::TextLayout; +use vectorcraft_tools::bbox::Handle; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// 12 pt area type in a `w` × `h` frame at (`x`, `y`). +fn area(s: &mut Session, x: f64, y: f64, w: f64, h: f64, text: &str) -> NodeId { + id_of(&s.execute("text.create", &json!({"x": x, "y": y, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap()) +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +/// The frame's anchors in text space. +fn corners(s: &Session, id: NodeId) -> Vec { + match text(s, id).kind { + TextKind::Area { frame } => frame.anchors().map(|(_, _, a)| a.p).collect(), + k => panic!("not area type: {k:?}"), + } +} + +fn frame(s: &Session, id: NodeId) -> Rect { + match text(s, id).kind { + TextKind::Area { frame } => frame.bounds().unwrap(), + k => panic!("not area type: {k:?}"), + } +} + +fn layout(s: &Session, id: NodeId) -> TextLayout { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn last_step(s: &Session) -> String { + s.doc().unwrap().history.undo.last().unwrap().label.clone() +} + +/// Press, drag and release `tool` from `from` to `to`. +fn drag(s: &mut Session, tool: &str, from: Point, to: Point) { + let v = ViewInfo::default(); + s.select_tool(tool, v).unwrap(); + for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap(); + } + assert!(!s.in_interaction()); +} + +fn near(a: Point, b: Point) -> bool { + a.distance(b) < 1e-6 +} + +#[test] +fn a_handle_drag_resizes_the_type_area_and_the_text_reflows() { + let mut s = session(); + let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY); + let before = layout(&s, id); + assert!(before.overflow, "the story overflows 120 × 40"); + sel(&mut s, &[id]); + let n = undo_len(&s); + // The bounding box's bottom-right handle, from (160, 80) to (200, 140): 160 × 100. + drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(200.0, 140.0)); + assert_eq!(undo_len(&s), n + 1, "one step"); + assert_eq!(last_step(&s), "Resize Type Area"); + let r = frame(&s, id); + assert!(near(r.origin(), Point::ZERO) && (r.width() - 160.0).abs() < 1e-6 && (r.height() - 100.0).abs() < 1e-6, "{r:?}"); + let t = text(&s, id); + assert_eq!(t.xf, Affine::translate((40.0, 40.0)), "the type isn't scaled"); + assert!(t.runs.iter().all(|r| r.style.size == 12.0)); + let after = layout(&s, id); + assert!(after.lines.len() > before.lines.len(), "more lines fit: {} → {}", before.lines.len(), after.lines.len()); + assert!(after.glyphs.len() > before.glyphs.len()); + assert!(!after.overflow, "the whole story fits 160 × 100"); + // The bounding box hugs the new frame. + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds(), Some(Rect::new(40.0, 40.0, 200.0, 140.0))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0)); + assert!(layout(&s, id).overflow); +} + +#[test] +fn point_type_still_scales_with_its_handles() { + let mut s = session(); + let id = id_of(&s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "text": "Hello"})).unwrap()); + sel(&mut s, &[id]); + let b = s.transform_box(&s.doc().unwrap().selection.objects).unwrap(); + let (tl, br) = (b.to_doc() * Handle::TopLeft.pos(b.rect), b.to_doc() * Handle::BottomRight.pos(b.rect)); + drag(&mut s, "selection", br, tl + (br - tl) * 2.0); + assert_eq!(last_step(&s), "Scale"); + let c = text(&s, id).xf.as_coeffs(); + assert!((c[0] - 2.0).abs() < 1e-6 && (c[3] - 2.0).abs() < 1e-6, "the type doubles: {c:?}"); +} + +#[test] +fn with_other_objects_the_type_area_resizes_and_the_others_scale() { + let mut s = session(); + let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 100, "width": 120, "height": 20})).unwrap()); + sel(&mut s, &[id, r]); + // The box spans (40, 40)–(160, 120); its right handle doubles the width. + drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(280.0, 80.0)); + assert_eq!(last_step(&s), "Scale"); + assert_eq!(s.doc().unwrap().doc.node(r).unwrap().geometric_bounds(), Some(Rect::new(40.0, 100.0, 280.0, 120.0))); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 240.0, 40.0)); + assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0))); +} + +#[test] +fn a_rotated_type_area_resizes_along_its_own_axes() { + let mut s = session(); + let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY); + sel(&mut s, &[id]); + s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap(); + let xf = text(&s, id).xf; + let b = s.transform_box(&[id]).unwrap(); + let right = b.to_doc() * Handle::Right.pos(b.rect); + let along = (b.to_doc() * Handle::Right.pos(b.rect)) - (b.to_doc() * Handle::Left.pos(b.rect)); + drag(&mut s, "selection", right, right + along * 0.5); + assert_eq!(text(&s, id).xf, xf, "the type keeps its size and angle"); + let r = frame(&s, id); + assert!((r.width() - 180.0).abs() < 1e-6 && (r.height() - 40.0).abs() < 1e-6, "still a rectangle, 180 × 40: {r:?}"); + assert_eq!(corners(&s, id).len(), 4); +} + +#[test] +fn direct_selection_drags_a_frame_corner_or_edge() { + let mut s = session(); + let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY); + let glyphs = layout(&s, id).glyphs.len(); + let n = undo_len(&s); + // The bottom-right corner, not selected yet: the corner moves, the others stay. + drag(&mut s, "directSelection", Point::new(160.0, 80.0), Point::new(200.0, 140.0)); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + assert_eq!(undo_len(&s), n + 1, "one step"); + assert_eq!(last_step(&s), "Reshape Type Area"); + let c = corners(&s, id); + assert!(near(c[0], Point::ZERO) && near(c[1], Point::new(120.0, 0.0)) && near(c[3], Point::new(0.0, 40.0)), "{c:?}"); + assert!(near(c[2], Point::new(160.0, 100.0)), "{c:?}"); + assert!(layout(&s, id).glyphs.len() > glyphs, "more text fits the larger area"); + assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0)); + // The right edge: both its ends move, and the area stays a rectangle. + drag(&mut s, "directSelection", Point::new(160.0, 60.0), Point::new(180.0, 60.0)); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 140.0, 40.0)); + assert_eq!(text(&s, id).runs[0].style.size, 12.0); +} + +#[test] +fn a_resized_frame_reflows_its_thread() { + let mut s = session(); + let a = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY); + let b = area(&mut s, 300.0, 40.0, 120.0, 200.0, ""); + sel(&mut s, &[a, b]); + s.execute("text.thread.create", &json!({})).unwrap(); + let rest = text(&s, b).plain_text().len(); + assert!(rest > 0, "the overflow flows on"); + // Bounding-box resize of the first frame to 160 × 100, as the Selection tool sends it. + let m = Affine::translate((40.0, 40.0)) * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * Affine::translate((-40.0, -40.0)); + let c = m.as_coeffs(); + s.execute("object.transform", &json!({"ids": [a.0], "matrix": c, "typeAreas": true})).unwrap(); + assert!(text(&s, b).plain_text().len() < rest, "the second frame holds less"); + assert_eq!(text(&s, a).plain_text().len() + text(&s, b).plain_text().len(), STORY.len()); +} + +#[test] +fn area_type_options_size_the_type_area() { + let mut s = session(); + let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY); + sel(&mut s, &[id]); + let q = s.execute("text.areaOptions", &json!({})).unwrap(); + assert_eq!((q["width"].as_f64(), q["height"].as_f64()), (Some(120.0), Some(40.0))); + let n = undo_len(&s); + let r = s.execute("text.areaOptions", &json!({"width": 160, "height": 100})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(160.0), Some(100.0))); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0)); + assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0))); + assert!(!layout(&s, id).overflow); + // The dialog's OK sends the size back unchanged with the other options: only those change. + s.execute("text.areaOptions", &json!({"width": 160, "height": 100, "inset": 4})).unwrap(); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0)); + assert_eq!(undo_len(&s), n + 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0)); + // Type scaled 200%: sizes are in points on the page, along the type's axes. + s.execute("object.scale", &json!({"sx": 200, "origin": [40, 40]})).unwrap(); + assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["width"].as_f64(), Some(240.0)); + s.execute("text.areaOptions", &json!({"width": 300})).unwrap(); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 150.0, 40.0)); +} + +#[test] +fn reshape_area_takes_untrusted_params() { + let mut s = session(); + let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY); + let point = id_of(&s.execute("text.create", &json!({"x": 40, "y": 300, "text": "Point"})).unwrap()); + let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 50, "height": 50})).unwrap()); + let n = undo_len(&s); + for bad in [ + json!({}), + json!({"anchors": [[0, 2]], "dx": 1, "dy": 1}), + json!({"id": point.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}), + json!({"id": rect.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}), + json!({"id": 99_999, "anchors": [[0, 0]], "dx": 1, "dy": 1}), + json!({"id": id.0, "dx": 1, "dy": 1}), + json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}), + json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}), + json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}), + json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}), + json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}), + json!({"id": id.0, "anchors": [[0, 2]], "dx": "far", "dy": 1}), + json!({"id": id.0, "anchors": [[0, 2]], "dx": 0, "dy": 1e300}), + json!({"id": id.0, "anchors": [[0, 2]], "dx": -1e7, "dy": 0}), + ] { + assert!(s.execute("text.reshapeArea", &bad).is_err(), "{bad}"); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0)); + // Listed twice, an anchor still moves once. + s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 2], [0, 2]], "dx": 10, "dy": 5})).unwrap(); + assert_eq!(corners(&s, id)[2], Point::new(130.0, 45.0)); + assert_eq!(last_step(&s), "Reshape Type Area"); + // A frame collapsed to nothing lays out (everything overflows) without trouble. + for m in [[0.0, 0.0, 0.0, 1.0, 40.0, 0.0], [1.0, 0.0, 0.0, 0.0, 0.0, 40.0], [0.0; 6]] { + s.execute("object.transform", &json!({"ids": [id.0], "matrix": m, "typeAreas": true})).unwrap(); + assert!(layout(&s, id).overflow); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +#[test] +fn reshape_area_is_a_registered_command() { + let spec = cmd::find_command("text.reshapeArea").unwrap(); + assert_eq!(spec.label, "Reshape Type Area"); + assert!(spec.params.contains("anchors") && spec.params.contains("dx")); + assert!(cmd::find_command("object.transform").unwrap().params.contains("typeAreas")); + assert!(session().commands().iter().any(|c| c.id == "text.reshapeArea" && c.enabled)); +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 998d4aea..d97c3ab1 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -7,16 +7,20 @@ //! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its //! points (a key object on a point moves with it) and, once a point is clicked, its handles; and //! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]). +//! Dragging a corner or an edge of area type's frame reshapes the type area (`text.reshapeArea`): +//! the text reflows at its size. + +use std::borrow::Cow; use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; -use vectorcraft_doc::{AnchorRef, NodeId, NodeKind}; +use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, Point, Rect}; use crate::bbox::move_delta; use crate::corners::{CornerDrag, over_widget}; use crate::meshedit::MeshEdit; -use crate::select::matrix_json; +use crate::select::{is_area_type, matrix_json}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; #[derive(Clone, Debug)] @@ -58,6 +62,13 @@ enum State { }, /// Dragging a mesh point or handle ([`MeshEdit`]). Mesh, + /// Dragging anchors of area type's frame (a corner, or the two ends of an edge). + TypeArea { + id: NodeId, + anchors: Vec, + start: Point, + began: bool, + }, } pub struct DirectSelectionTool { @@ -115,33 +126,45 @@ fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option Option<(NodeId, usize, usize)> { - let ids: Vec = if selected_only { - cx.selection.objects.clone() - } else { - let mut v = vec![]; - cx.doc.walk(|n| { - if matches!(n.kind, NodeKind::Path { .. }) { - v.push(n.id) - } - }); - v.reverse(); - v - }; - for id in ids { - if !cx.doc.is_editable(id) { - continue; - } - if let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) { - for (si, ai, a) in pd.anchors() { - if a.p.distance(p) <= tol { - return Some((id, si, ai)); - } - } - } +/// The anchors Direct Selection edits on `n`, in document space: a path's, or area type's frame. +fn editable_path(n: &Node) -> Option> { + match &n.kind { + NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)), + NodeKind::Text(t) => t.area_frame().map(Cow::Owned), + _ => None, } - None +} + +/// The editable objects `f` accepts, topmost first. +fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec { + let mut v = vec![]; + cx.doc.walk(|n| { + if f(n) { + v.push(n.id) + } + }); + v.reverse(); + v.retain(|id| cx.doc.is_editable(*id)); + v +} + +/// Anchor of any visible path or area type frame under `p`, topmost first. +fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> { + let owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n)); + owners.into_iter().find_map(|id| { + let pd = cx.doc.node(id).and_then(editable_path)?; + pd.anchors().find(|(_, _, a)| a.p.distance(p) <= tol).map(|(si, ai, _)| (id, si, ai)) + }) +} + +/// An edge of an area type frame under `p`, topmost first: (text, the edge's two anchors). +fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec)> { + anchor_owners(cx, is_area_type).into_iter().find_map(|id| { + let frame = cx.doc.node(id).and_then(editable_path)?; + let (si, seg, _, _, d) = frame.nearest(p)?; + let n = frame.subpaths.get(si)?.anchors.len(); + (d <= tol && n > 0).then(|| (id, vec![(si, seg % n), (si, (seg + 1) % n)])) + }) } /// Direction handle of a partially selected anchor under `p`: (id, si, ai, is_out). @@ -223,7 +246,10 @@ impl Tool for DirectSelectionTool { self.state = State::SpinePoint { id, anchor, from, start: p, began: false }; return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; } - if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) { + if let Some((id, si, ai)) = hit_anchor(cx, p, tol) { + if cx.doc.node(id).is_some_and(is_area_type) { + return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift); + } let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai))); self.state = State::MoveAnchors { start: p, began: false }; if ev.mods.shift { @@ -234,6 +260,9 @@ impl Tool for DirectSelectionTool { } return vec![]; } + if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) { + return self.press_type_area(cx, id, anchors, p, ev.mods.shift); + } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { // Clicking a segment/fill selects the whole leaf path (all anchors). self.state = State::MoveObject { start: p, began: false }; @@ -277,6 +306,19 @@ impl Tool for DirectSelectionTool { )); out } + (PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Reshape Type Area".into())); + } + let d = move_delta(start, p, ev.mods.shift); + out.push(Action::Preview("text.reshapeArea".into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y}))); + self.state = State::TypeArea { id, anchors, start, began: true }; + out + } (PointerKind::Drag, State::Handle { id, si, ai, out }) => { vec![Action::Preview( "path.setHandle".into(), @@ -318,7 +360,10 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; c.finish() } - (PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. }) => { + ( + PointerKind::Up, + State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. } | State::TypeArea { began, .. }, + ) => { self.state = State::Idle; if began { vec![Action::Commit] } else { vec![] } } @@ -356,6 +401,9 @@ impl Tool for DirectSelectionTool { _ => { let mut out = self.spine_overlays(cx); out.extend(self.mesh.overlays(cx)); + if !self.group { + out.extend(frame_overlays(cx)); + } out } } @@ -368,7 +416,31 @@ impl Tool for DirectSelectionTool { } } +/// The frame anchors of the selected area type, which Direct Selection drags. +fn frame_overlays(cx: &ToolContext) -> Vec { + let mut out = vec![]; + for id in &cx.selection.objects { + let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n)).and_then(editable_path) else { continue }; + let color = cx.doc.layer_color(*id); + out.extend(frame.anchors().map(|(_, _, a)| Overlay::Anchor { p: a.p, color, filled: false, size: 5.0 })); + } + out +} + impl DirectSelectionTool { + /// Press on area type's frame anchors (a corner, or an edge's two ends): select the text and + /// get ready to drag them. + fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, anchors: Vec, p: Point, shift: bool) -> Vec { + self.state = State::TypeArea { id, anchors, start: p, began: false }; + if cx.selection.contains(id) { + vec![] + } else if shift { + vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))] + } else { + vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] + } + } + /// The clicked spine point, filled, with its handles. fn spine_overlays(&self, cx: &ToolContext) -> Vec { let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return vec![] }; @@ -435,6 +507,37 @@ mod tests { assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); } + #[test] + fn dragging_a_frame_corner_or_edge_reshapes_area_type() { + let (d, text) = doc_with_area_type(); + let p = paint(); + let s = Selection::default(); + let cx1 = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + // The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text... + assert_eq!( + t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)), + vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))] + ); + // ...and drags that corner. + let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0)); + assert_eq!(a[0], Action::Begin("Reshape Type Area".into())); + assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0}))); + assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]); + // The top edge drags both its ends; a click alone changes nothing. + let mut s = Selection::default(); + s.add(text); + let cx2 = cx(&d, &s, &p); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 350.0, 301.0)).is_empty()); + let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0)); + assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0}))); + t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0)); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty()); + assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty()); + // The selected frame's corners show. + assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4); + } + #[test] fn click_anchor_selects_it() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 15faeb89..5fa358fd 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -477,6 +477,17 @@ pub(crate) mod testutil { (d, id) } + /// [`doc_with_rect`] plus 120 × 40 area type at (300, 300). + pub fn doc_with_area_type() -> (Document, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut t = vectorcraft_doc::TextObject::point(Point::new(300.0, 300.0), "Some words", Default::default()); + t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 40.0)) }; + d.insert(Some(l), 1, Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t)))).unwrap(); + (d, id) + } + pub fn paint() -> PaintDefaults { PaintDefaults::default() } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 2cc3d09e..cea3fef2 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -2,7 +2,8 @@ //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), //! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode. //! The bounding box stands at the selection's own angle after a rotation, so its handles scale -//! along the objects' axes. +//! along the objects' axes. A handle drag resizes area type's frame (the type area) instead of +//! scaling its type: the text reflows at its size. use serde_json::{Value, json}; use vectorcraft_doc::hit::{hit_test, marquee}; @@ -28,6 +29,8 @@ enum State { Scaling { handle: Handle, bx: OrientedBox, + /// Area type is selected: its frame resizes (`typeAreas`), its type keeps its size. + areas: bool, }, Rotating { center: Point, @@ -129,8 +132,12 @@ impl Tool for SelectionTool { { match box_hit(cx, &bx, p) { Some(BoxHit::Handle(handle)) => { - self.state = State::Scaling { handle, bx }; - return vec![Action::Begin("Scale".into())]; + let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type); + let areas = cx.selection.objects.iter().any(is_area); + // Only area type: the step resizes type areas; anything else scales. + let label = if areas && cx.selection.objects.iter().all(is_area) { "Resize Type Area" } else { "Scale" }; + self.state = State::Scaling { handle, bx, areas }; + return vec![Action::Begin(label.into())]; } Some(BoxHit::Rotate) => { self.state = State::Rotating { center: bx.center(), start: p }; @@ -196,12 +203,16 @@ impl Tool for SelectionTool { out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out } - (PointerKind::Drag, State::Scaling { handle, bx }) => { + (PointerKind::Drag, State::Scaling { handle, bx, areas }) => { // Scale in the box's own frame: along the objects' axes when it is rotated. let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); let nr = a.transform_rect_bbox(bx.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); - vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))] + let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }); + if areas { + params["typeAreas"] = json!(true); + } + vec![Action::Preview("object.transform".into(), params)] } (PointerKind::Drag, State::Rotating { center, start, .. }) => { let (a, deg) = rotate_for_drag(center, start, p, m.shift); @@ -271,7 +282,7 @@ impl Tool for SelectionTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.state { State::Rotating { .. } => return Cursor::Rotate, - State::Scaling { handle, bx } => return handle_cursor(handle, bx.angle), + State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle), State::Moving { began: true, .. } => return Cursor::Arrow, State::Corner(_) => return Cursor::CornerRadius, _ => {} @@ -298,6 +309,12 @@ impl Tool for SelectionTool { } } +/// Is `n` area type (text in a frame) whose frame the tools reshape? Type in perspective isn't: +/// it transforms whole. +pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { + n.perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) +} + /// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the /// handle that sits where `h` appears on screen, in 45° steps. fn handle_cursor(h: Handle, angle: f64) -> Cursor { @@ -420,6 +437,36 @@ mod tests { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } + #[test] + fn handle_drag_on_area_type_resizes_its_frame() { + let (d, text) = doc_with_area_type(); + let p = paint(); + let mut s = Selection::default(); + s.add(text); + let cx1 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + // Bottom-right handle of the 120 × 40 frame at (300, 300). + assert_eq!(t.pointer(&cx1, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Resize Type Area".into())]); + let a = t.pointer(&cx1, &ev(PointerKind::Drag, 460.0, 400.0)); + assert!(matches!(&a[0], Action::Preview(c, v) if c == "object.transform" && v["typeAreas"] == true), "{a:?}"); + assert_eq!(t.pointer(&cx1, &ev(PointerKind::Up, 460.0, 400.0)), vec![Action::Commit]); + // With another object the step is a scale, still resizing the type area. + let rect = d.layers[0].children().unwrap()[0].id; + s.add(rect); + let cx2 = cx(&d, &s, &p); + assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&cx2, &ev(PointerKind::Drag, 460.0, 400.0)); + assert!(matches!(&a[0], Action::Preview(_, v) if v["typeAreas"] == true), "{a:?}"); + t.pointer(&cx2, &ev(PointerKind::Up, 460.0, 400.0)); + // Without area type the drag scales as before. + let mut s = Selection::default(); + s.add(rect); + let cx3 = cx(&d, &s, &p); + t.pointer(&cx3, &ev(PointerKind::Down, 200.0, 200.0)); + let a = t.pointer(&cx3, &ev(PointerKind::Drag, 300.0, 300.0)); + assert!(matches!(&a[0], Action::Preview(_, v) if v.get("typeAreas").is_none()), "{a:?}"); + } + #[test] fn turned_box_handles_turn_and_scale_along_the_object() { let (mut d, id) = doc_with_rect(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ebf775b9..3b1e31e9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -829,11 +829,18 @@ fn c32(rgb: [u8; 3]) -> Color32 { Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } -/// Outline of a node for highlighting (paths, compound children, text/image bounds). +/// Outline of a node for highlighting (paths, compound children, area type frames, other +/// text/image bounds). fn node_outline(n: &Node) -> BezPath { let mut bp = BezPath::new(); walk_drawn(n, &mut |c| match &c.kind { NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), + // Area type shows its frame: the type area Direct Selection reshapes. + NodeKind::Text(t) if c.perspective.is_none() && matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => { + if let Some(frame) = t.area_frame() { + bp.extend(frame.to_bezpath()); + } + } NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { if let Some(b) = c.geometric_bounds() { bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); @@ -1162,8 +1169,10 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { { anchor_square(p, xf.to_screen(b.center()), color, true, 4.0); } - // Text: baseline marker. - if let NodeKind::Text(tx) = &n.kind { + // Text: baseline marker (area type shows its frame instead, which may not be a rectangle). + if let NodeKind::Text(tx) = &n.kind + && !matches!(tx.kind, vectorcraft_doc::TextKind::Area { .. }) + { let o = xf.to_screen(tx.xf * Point::ZERO); let b = n.geometric_bounds().unwrap_or_default(); let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y)); diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 401ba7f0..903605d0 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -26,7 +26,7 @@ fn lengths(command: &str) -> &'static [&'static str] { "artboard.rearrange" => &["spacing"], "perspective.grid.set" => &["cell", "distance"], "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], - "text.areaOptions" => &["gutter", "inset", "firstBaselineMin"], + "text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"], _ => &[], } } diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9cd6fe19..77ef28c4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2023,6 +2023,7 @@ Reshape 改變外框 Reshape Segment 改變線段外框 Reshape Tool 改變外框工具 + Reshape Type Area 改變區域文字外框 Resolution: 解析度: Responsive 回應式 Restore 還原 diff --git a/docs/mcp.md b/docs/mcp.md index d2f632db..0b9a65a0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1790,6 +1790,20 @@ its undo history are as if the click never happened. Commands on the edited text {"name":"run_command","arguments":{"command":"text.discardEmpty","params":{"id":42}}} ``` +## Resizing area type + +As in the reference app, dragging a bounding-box handle of area type resizes its type area instead of scaling the +type: the Selection tool sends `object.transform {matrix, typeAreas: true}`, which reshapes the frame of each area +type object it transforms (other objects, type inside groups and point type transform as usual) and reflows the +text at its size, through its thread too. `text.reshapeArea {id, anchors: [[subpath, anchor]…], dx, dy}` moves +frame anchors with their handles (Direct Selection dragging a corner, or an edge's two ends), and +`text.areaOptions {width?, height?}` sizes the area from its top-left corner (the query returns both). Each is one +undo step. Object › Transform › Scale, the Scale tool and the Transform panel still scale the type. + +```json +{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}} +``` + ## Constrain proportions The link between W and H in the Transform panel, the Properties panel and the Control bar is the