Merge pull request #289 from storytold/fix/issue-252
Fix #252: area type resizes its frame with the mouse
This commit is contained in:
commit
68a14472c6
|
|
@ -116,7 +116,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||||
- **Geometry and effects:**
|
- **Geometry and effects:**
|
||||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
- 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).
|
- 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.
|
- **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 `<mask>` and PDF soft masks.
|
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` 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).
|
- **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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use vectorcraft_color::{Color, Paint};
|
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};
|
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||||
|
|
||||||
|
|
@ -476,6 +476,49 @@ impl TextObject {
|
||||||
pub fn transform(&mut self, a: Affine) {
|
pub fn transform(&mut self, a: Affine) {
|
||||||
self.xf = a * self.xf;
|
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<PathData> {
|
||||||
|
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
|
/// 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).
|
/// `1 / scale` keeps their weight through a transform that scales the type).
|
||||||
pub fn scale_char_strokes(&mut self, s: f64) {
|
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!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
|
||||||
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Transform",
|
"Transform",
|
||||||
[],
|
[],
|
||||||
None,
|
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,
|
has_doc,
|
||||||
transform
|
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 }
|
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`]).
|
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
|
||||||
/// Records Transform Again.
|
/// `typeAreas`: see [`resize_type_area`]). Records Transform Again.
|
||||||
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
||||||
let copy = bool_or(p, "copy", false);
|
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 sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||||
let ids = s.edit(label, |d, sel| {
|
let ids = s.edit(label, |d, sel| {
|
||||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||||
for id in &targets {
|
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);
|
n.transform(xf, sc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +227,17 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf
|
||||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Value> {
|
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
|
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") {
|
let ids = match ids_param(p, "ids") {
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,8 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
ok()
|
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)
|
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())
|
.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()
|
.unwrap_or_default()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
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::edit::selected_roots;
|
||||||
use super::pathops::{len_param, num_param, shape_node};
|
use super::pathops::{len_param, num_param, shape_node};
|
||||||
|
|
@ -48,10 +48,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Area Type Options…",
|
"Area Type Options…",
|
||||||
["Type"],
|
["Type"],
|
||||||
None,
|
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,
|
has_selection,
|
||||||
area_options
|
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!(
|
cmd!(
|
||||||
"text.fitHeadline",
|
"text.fitHeadline",
|
||||||
"Fit Headline",
|
"Fit Headline",
|
||||||
|
|
@ -265,25 +274,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<f64>, h: Option<f64>) -> bool {
|
||||||
|
let Some((cw, ch, b)) = area_size(t) else { return false };
|
||||||
|
let factor = |to: Option<f64>, 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<Value> {
|
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
const C: &str = "text.areaOptions";
|
const C: &str = "text.areaOptions";
|
||||||
let ids: Vec<NodeId> = {
|
let ids: Vec<NodeId> = {
|
||||||
let d = &s.doc()?.doc;
|
let d = &s.doc()?.doc;
|
||||||
text_targets(s, p, C)?
|
text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect()
|
||||||
.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()
|
|
||||||
};
|
};
|
||||||
let first = ids.first().ok_or_else(|| 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 current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) {
|
let options = |s: &Session| -> Result<Value> {
|
||||||
Some(NodeKind::Text(t)) => t.area.clone(),
|
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||||
_ => return Err(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 v = options(s)?;
|
||||||
let mut changed = false;
|
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()) {
|
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
|
||||||
for (k, val) in src {
|
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());
|
o.insert(k.clone(), val.clone());
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
@ -298,17 +364,43 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
||||||
opts.inset = opts.inset.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);
|
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, _| {
|
s.edit("Area Type Options", |d, _| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||||
t.area = opts.clone();
|
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(())
|
||||||
})?;
|
})?;
|
||||||
Ok(out)
|
options(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -1388,6 +1388,8 @@ mod tests_toolsettings;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_transparencygrid;
|
mod tests_transparencygrid;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_typearea;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_units;
|
mod tests_units;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_webexport;
|
mod tests_webexport;
|
||||||
|
|
|
||||||
|
|
@ -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::<Vec<_>>()})).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<Point> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
@ -7,16 +7,20 @@
|
||||||
//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its
|
//! 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
|
//! 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`]).
|
//! 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 serde_json::{Value, json};
|
||||||
use vectorcraft_doc::hit::hit_test;
|
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 vectorcraft_geom::{PathData, Point, Rect};
|
||||||
|
|
||||||
use crate::bbox::move_delta;
|
use crate::bbox::move_delta;
|
||||||
use crate::corners::{CornerDrag, over_widget};
|
use crate::corners::{CornerDrag, over_widget};
|
||||||
use crate::meshedit::MeshEdit;
|
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};
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -58,6 +62,13 @@ enum State {
|
||||||
},
|
},
|
||||||
/// Dragging a mesh point or handle ([`MeshEdit`]).
|
/// Dragging a mesh point or handle ([`MeshEdit`]).
|
||||||
Mesh,
|
Mesh,
|
||||||
|
/// Dragging anchors of area type's frame (a corner, or the two ends of an edge).
|
||||||
|
TypeArea {
|
||||||
|
id: NodeId,
|
||||||
|
anchors: Vec<AnchorRef>,
|
||||||
|
start: Point,
|
||||||
|
began: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DirectSelectionTool {
|
pub struct DirectSelectionTool {
|
||||||
|
|
@ -115,33 +126,45 @@ fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option<vectorcra
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Anchor (or handle) of any selected/visible path under `p`.
|
/// The anchors Direct Selection edits on `n`, in document space: a path's, or area type's frame.
|
||||||
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64, selected_only: bool) -> Option<(NodeId, usize, usize)> {
|
fn editable_path(n: &Node) -> Option<Cow<'_, PathData>> {
|
||||||
let ids: Vec<NodeId> = if selected_only {
|
match &n.kind {
|
||||||
cx.selection.objects.clone()
|
NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)),
|
||||||
} else {
|
NodeKind::Text(t) => t.area_frame().map(Cow::Owned),
|
||||||
let mut v = vec![];
|
_ => None,
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None
|
}
|
||||||
|
|
||||||
|
/// The editable objects `f` accepts, topmost first.
|
||||||
|
fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
|
||||||
|
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<AnchorRef>)> {
|
||||||
|
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).
|
/// 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 };
|
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]}))] };
|
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)));
|
let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai)));
|
||||||
self.state = State::MoveAnchors { start: p, began: false };
|
self.state = State::MoveAnchors { start: p, began: false };
|
||||||
if ev.mods.shift {
|
if ev.mods.shift {
|
||||||
|
|
@ -234,6 +260,9 @@ impl Tool for DirectSelectionTool {
|
||||||
}
|
}
|
||||||
return vec![];
|
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()) {
|
if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) {
|
||||||
// Clicking a segment/fill selects the whole leaf path (all anchors).
|
// Clicking a segment/fill selects the whole leaf path (all anchors).
|
||||||
self.state = State::MoveObject { start: p, began: false };
|
self.state = State::MoveObject { start: p, began: false };
|
||||||
|
|
@ -277,6 +306,19 @@ impl Tool for DirectSelectionTool {
|
||||||
));
|
));
|
||||||
out
|
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 }) => {
|
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||||
vec![Action::Preview(
|
vec![Action::Preview(
|
||||||
"path.setHandle".into(),
|
"path.setHandle".into(),
|
||||||
|
|
@ -318,7 +360,10 @@ impl Tool for DirectSelectionTool {
|
||||||
self.state = State::Idle;
|
self.state = State::Idle;
|
||||||
c.finish()
|
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;
|
self.state = State::Idle;
|
||||||
if began { vec![Action::Commit] } else { vec![] }
|
if began { vec![Action::Commit] } else { vec![] }
|
||||||
}
|
}
|
||||||
|
|
@ -356,6 +401,9 @@ impl Tool for DirectSelectionTool {
|
||||||
_ => {
|
_ => {
|
||||||
let mut out = self.spine_overlays(cx);
|
let mut out = self.spine_overlays(cx);
|
||||||
out.extend(self.mesh.overlays(cx));
|
out.extend(self.mesh.overlays(cx));
|
||||||
|
if !self.group {
|
||||||
|
out.extend(frame_overlays(cx));
|
||||||
|
}
|
||||||
out
|
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<Overlay> {
|
||||||
|
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 {
|
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<AnchorRef>, p: Point, shift: bool) -> Vec<Action> {
|
||||||
|
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.
|
/// The clicked spine point, filled, with its handles.
|
||||||
fn spine_overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
fn spine_overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||||
let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return 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]}))]);
|
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]
|
#[test]
|
||||||
fn click_anchor_selects_it() {
|
fn click_anchor_selects_it() {
|
||||||
let (d, id) = doc_with_rect();
|
let (d, id) = doc_with_rect();
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,17 @@ pub(crate) mod testutil {
|
||||||
(d, id)
|
(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 {
|
pub fn paint() -> PaintDefaults {
|
||||||
PaintDefaults::default()
|
PaintDefaults::default()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
//! 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.
|
//! 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
|
//! 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 serde_json::{Value, json};
|
||||||
use vectorcraft_doc::hit::{hit_test, marquee};
|
use vectorcraft_doc::hit::{hit_test, marquee};
|
||||||
|
|
@ -28,6 +29,8 @@ enum State {
|
||||||
Scaling {
|
Scaling {
|
||||||
handle: Handle,
|
handle: Handle,
|
||||||
bx: OrientedBox,
|
bx: OrientedBox,
|
||||||
|
/// Area type is selected: its frame resizes (`typeAreas`), its type keeps its size.
|
||||||
|
areas: bool,
|
||||||
},
|
},
|
||||||
Rotating {
|
Rotating {
|
||||||
center: Point,
|
center: Point,
|
||||||
|
|
@ -129,8 +132,12 @@ impl Tool for SelectionTool {
|
||||||
{
|
{
|
||||||
match box_hit(cx, &bx, p) {
|
match box_hit(cx, &bx, p) {
|
||||||
Some(BoxHit::Handle(handle)) => {
|
Some(BoxHit::Handle(handle)) => {
|
||||||
self.state = State::Scaling { handle, bx };
|
let is_area = |id: &NodeId| cx.doc.node(*id).is_some_and(is_area_type);
|
||||||
return vec![Action::Begin("Scale".into())];
|
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) => {
|
Some(BoxHit::Rotate) => {
|
||||||
self.state = State::Rotating { center: bx.center(), start: p };
|
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.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt })));
|
||||||
out
|
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.
|
// 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 a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt);
|
||||||
let nr = a.transform_rect_bbox(bx.rect);
|
let nr = a.transform_rect_bbox(bx.rect);
|
||||||
self.measure = Some((p, cx.size_label(nr.width(), nr.height())));
|
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, .. }) => {
|
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
||||||
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
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 {
|
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||||
match self.state {
|
match self.state {
|
||||||
State::Rotating { .. } => return Cursor::Rotate,
|
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::Moving { began: true, .. } => return Cursor::Arrow,
|
||||||
State::Corner(_) => return Cursor::CornerRadius,
|
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
|
/// 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.
|
/// handle that sits where `h` appears on screen, in 45° steps.
|
||||||
fn handle_cursor(h: Handle, angle: f64) -> Cursor {
|
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]);
|
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]
|
#[test]
|
||||||
fn turned_box_handles_turn_and_scale_along_the_object() {
|
fn turned_box_handles_turn_and_scale_along_the_object() {
|
||||||
let (mut d, id) = doc_with_rect();
|
let (mut d, id) = doc_with_rect();
|
||||||
|
|
|
||||||
|
|
@ -829,11 +829,18 @@ fn c32(rgb: [u8; 3]) -> Color32 {
|
||||||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
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 {
|
fn node_outline(n: &Node) -> BezPath {
|
||||||
let mut bp = BezPath::new();
|
let mut bp = BezPath::new();
|
||||||
walk_drawn(n, &mut |c| match &c.kind {
|
walk_drawn(n, &mut |c| match &c.kind {
|
||||||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
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 { .. } => {
|
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||||
if let Some(b) = c.geometric_bounds() {
|
if let Some(b) = c.geometric_bounds() {
|
||||||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
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);
|
anchor_square(p, xf.to_screen(b.center()), color, true, 4.0);
|
||||||
}
|
}
|
||||||
// Text: baseline marker.
|
// Text: baseline marker (area type shows its frame instead, which may not be a rectangle).
|
||||||
if let NodeKind::Text(tx) = &n.kind {
|
if let NodeKind::Text(tx) = &n.kind
|
||||||
|
&& !matches!(tx.kind, vectorcraft_doc::TextKind::Area { .. })
|
||||||
|
{
|
||||||
let o = xf.to_screen(tx.xf * Point::ZERO);
|
let o = xf.to_screen(tx.xf * Point::ZERO);
|
||||||
let b = n.geometric_bounds().unwrap_or_default();
|
let b = n.geometric_bounds().unwrap_or_default();
|
||||||
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
|
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ fn lengths(command: &str) -> &'static [&'static str] {
|
||||||
"artboard.rearrange" => &["spacing"],
|
"artboard.rearrange" => &["spacing"],
|
||||||
"perspective.grid.set" => &["cell", "distance"],
|
"perspective.grid.set" => &["cell", "distance"],
|
||||||
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
"object.repeat.options" => &["radius", "hSpacing", "vSpacing"],
|
||||||
"text.areaOptions" => &["gutter", "inset", "firstBaselineMin"],
|
"text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"],
|
||||||
_ => &[],
|
_ => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2023,6 +2023,7 @@
|
||||||
Reshape 改變外框
|
Reshape 改變外框
|
||||||
Reshape Segment 改變線段外框
|
Reshape Segment 改變線段外框
|
||||||
Reshape Tool 改變外框工具
|
Reshape Tool 改變外框工具
|
||||||
|
Reshape Type Area 改變區域文字外框
|
||||||
Resolution: 解析度:
|
Resolution: 解析度:
|
||||||
Responsive 回應式
|
Responsive 回應式
|
||||||
Restore 還原
|
Restore 還原
|
||||||
|
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
14
docs/mcp.md
14
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}}}
|
{"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
|
## Constrain proportions
|
||||||
|
|
||||||
The link between W and H in the Transform panel, the Properties panel and the Control bar is the
|
The link between W and H in the Transform panel, the Properties panel and the Control bar is the
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue