mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
@@ -128,7 +128,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR
|
||||
|
||||
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
|
||||
/// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there.
|
||||
fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
|
||||
pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
|
||||
if !doc.has_masks() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -535,6 +535,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)),
|
||||
spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)),
|
||||
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
|
||||
spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| {
|
||||
tool(s, ToolId::TextOnPath)
|
||||
}),
|
||||
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
|
||||
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
|
||||
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
|
||||
|
||||
@@ -70,6 +70,7 @@ fn scene() -> (Session, NodeId, NodeId) {
|
||||
|
||||
const EMPTY: (f64, f64) = (700.0, 600.0);
|
||||
const ON_SQUARE: (f64, f64) = (150.0, 150.0);
|
||||
const ON_SQUARE_EDGE: (f64, f64) = (101.0, 150.0);
|
||||
const ON_TEXT: (f64, f64) = (420.0, 120.0);
|
||||
|
||||
#[test]
|
||||
@@ -101,6 +102,9 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() {
|
||||
(ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"),
|
||||
(ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"),
|
||||
(ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"),
|
||||
(ToolId::TextOnPath, NONE, EMPTY, Default, "text on a path: a click on empty canvas does nothing"),
|
||||
(ToolId::TextOnPath, NONE, ON_SQUARE, Default, "nor inside a shape, away from its path"),
|
||||
(ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"),
|
||||
(ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"),
|
||||
(ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"),
|
||||
(ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"),
|
||||
|
||||
@@ -60,6 +60,149 @@ fn text_goes_onto_a_path_and_off_again() {
|
||||
assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back");
|
||||
}
|
||||
|
||||
/// A click (press and release) at (`x`, `y`) at 100%.
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
fn editing(s: &Session) -> Option<NodeId> {
|
||||
s.ds().unwrap().text_edit.as_ref().map(|te| te.id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_on_a_path_tool_types_new_text_onto_the_path_it_clicks() {
|
||||
let mut s = Session::new();
|
||||
let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap());
|
||||
s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap();
|
||||
let (vxf, vw, fills, effects) = {
|
||||
let n = s.doc().unwrap().node(v).unwrap();
|
||||
(n.transform, n.width, n.fills.clone(), n.effects.clone())
|
||||
};
|
||||
assert!(!fills.is_empty() && !effects.is_empty());
|
||||
s.set_tool(crate::tools::ToolId::TextOnPath);
|
||||
// Away from the path a click does nothing.
|
||||
click(&mut s, 140.0, 160.0);
|
||||
assert!(editing(&s).is_none() && s.doc().unwrap().node(v).is_some(), "only a path takes text");
|
||||
// A quarter of the way along, a few pixels off the line.
|
||||
click(&mut s, 90.0, 103.0);
|
||||
let t = editing(&s).expect("typing starts on the path");
|
||||
assert!(s.doc().unwrap().node(v).is_none(), "the path layer became the text's path");
|
||||
crate::textedit::insert(&mut s, "Along").unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let n = doc.node(t).unwrap();
|
||||
assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place");
|
||||
assert_eq!(n.kind.text().map(|t| t.characters.as_str()), Some("Along"));
|
||||
assert_eq!((&n.fills, &n.effects), (&fills, &effects), "the path's fill and effects carry over to the text");
|
||||
let tp = text_path(&s, t).unwrap();
|
||||
assert!((tp.start - 0.25).abs() < 0.01, "the text starts where the path was clicked: {}", tp.start);
|
||||
assert_eq!(s.tool.tool, crate::tools::ToolId::Move, "back to Move after placing, as with the Text tool");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(v).is_some() && doc.node(t).is_none(), "one undo step brings the path back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_in_front_whose_path_is_away_from_the_click_does_not_hide_the_path_behind() {
|
||||
let mut s = Session::new();
|
||||
let back = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100})).unwrap());
|
||||
// A filled square over the line: the click is inside it, 20 or more from its outline.
|
||||
let front = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 80, "width": 100, "height": 60})).unwrap());
|
||||
s.set_tool(crate::tools::ToolId::TextOnPath);
|
||||
click(&mut s, 90.0, 100.0);
|
||||
assert!(editing(&s).is_some(), "typing starts on the path behind");
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(back).is_none() && doc.node(front).is_some(), "the clicked path took the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_scaled_more_one_way_is_reached_where_it_shows() {
|
||||
let mut s = Session::new();
|
||||
let v = id(s.execute("vector.create", &json!({"path": "M0 0 L100 100", "x": 0, "y": 0})).unwrap());
|
||||
// Ten times wider than tall: on the page the line runs from (0, 0) to (1000, 100).
|
||||
s.doc_mut().unwrap().get_mut(v).unwrap().transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0);
|
||||
s.set_tool(crate::tools::ToolId::TextOnPath);
|
||||
// 5 below the line on the page, halfway along it.
|
||||
click(&mut s, 500.0, 55.0);
|
||||
let t = editing(&s).expect("typing starts on the path");
|
||||
let tp = text_path(&s, t).unwrap();
|
||||
assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start);
|
||||
// A layer shown at a tenth of its size: its outline is still reached 5 screen pixels away.
|
||||
let mut s = Session::new();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1000, "height": 1000})).unwrap());
|
||||
s.doc_mut().unwrap().get_mut(r).unwrap().transform = figcraft_geom::Affine::scale(0.1);
|
||||
s.set_tool(crate::tools::ToolId::TextOnPath);
|
||||
click(&mut s, 105.0, 50.0);
|
||||
assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none(), "typing starts on the small square's path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_a_mask_or_a_turned_clipping_frame_hides_take_no_text() {
|
||||
let mut s = Session::new();
|
||||
// A square masked by a circle in its top-left corner: its right edge doesn't show.
|
||||
let m = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("object.mask", &json!({"ids": [m, r]})).unwrap();
|
||||
// A clipping frame turned 45°, with a line outside it: inside the frame's bounding box, but clipped.
|
||||
let f = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100, "props": {"clip": true}})).unwrap());
|
||||
let l = id(s.execute("vector.create", &json!({"path": "M0 0 L40 0", "x": -60, "y": 50, "parent": f})).unwrap());
|
||||
s.execute("node.set", &json!({"id": f, "props": {"rotation": 45}})).unwrap();
|
||||
let hidden = s.doc().unwrap().world_transform(l) * figcraft_geom::Point::new(20.0, 0.0);
|
||||
let frame_box = s.doc().unwrap().world_bounds(f).unwrap();
|
||||
assert!(frame_box.contains(hidden), "the line's middle is inside the frame's bounding box");
|
||||
s.set_tool(crate::tools::ToolId::TextOnPath);
|
||||
for (x, y) in [(100.0, 50.0), (hidden.x, hidden.y)] {
|
||||
click(&mut s, x, y);
|
||||
assert!(editing(&s).is_none(), "nothing shows at ({x}, {y})");
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(r).is_some() && doc.node(l).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_stroke_only_path_gets_the_usual_fill() {
|
||||
let mut s = Session::new();
|
||||
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": {"fills": []}})).unwrap());
|
||||
assert!(!s.doc().unwrap().node(v).unwrap().fills.iter().any(|f| f.visible), "a path with a stroke only");
|
||||
s.set_tool(crate::tools::ToolId::TextOnPath);
|
||||
click(&mut s, 40.0, 100.0);
|
||||
let t = editing(&s).expect("typing starts on the path");
|
||||
assert!(s.doc().unwrap().node(t).unwrap().fills.iter().any(|f| f.visible), "the text can be seen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap());
|
||||
let locked = id(s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 100, "height": 100, "props": {"locked": true}})).unwrap());
|
||||
let before = (s.doc().unwrap().clone(), s.undo_labels(100).len());
|
||||
s.set_tool(crate::tools::ToolId::TextOnPath);
|
||||
click(&mut s, 400.0, 150.0);
|
||||
assert!(editing(&s).is_none(), "a locked layer's path doesn't take text");
|
||||
// Inside the square, away from its outline, nothing happens either.
|
||||
click(&mut s, 150.0, 150.0);
|
||||
assert!(editing(&s).is_none());
|
||||
click(&mut s, 100.0, 150.0);
|
||||
assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none());
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
assert_eq!(*s.doc().unwrap(), before.0, "the square is back as it was");
|
||||
assert_eq!(s.undo_labels(100).len(), before.1, "no undo step");
|
||||
assert!(s.doc().unwrap().node(locked).is_some());
|
||||
// A double-click on the path, then nothing typed: the same (with this tool and the Text tool).
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
for tool in [ToolId::TextOnPath, ToolId::Text] {
|
||||
s.set_tool(tool);
|
||||
click(&mut s, 100.0, 150.0);
|
||||
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 100.0, 150.0, 1.0)).unwrap();
|
||||
assert!(editing(&s).is_some(), "{tool:?}");
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
assert_eq!(*s.doc().unwrap(), before.0, "{tool:?}: the file is back as it was");
|
||||
assert_eq!(s.undo_labels(100).len(), before.1, "{tool:?}: no undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_text_paths_import_as_text_on_a_path() {
|
||||
// SVG text is parsed with Inter standing in for every family (CI has no fonts).
|
||||
|
||||
@@ -17,6 +17,8 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -
|
||||
let n = t.char_count();
|
||||
s.begin_interaction("Edit text")?;
|
||||
let ds = s.ds_mut()?;
|
||||
// Editing the same layer again (a double-click into a text just made) keeps it new.
|
||||
let created = created || ds.text_edit.as_ref().is_some_and(|te| te.id == id && te.created);
|
||||
let (caret, anchor) = match caret {
|
||||
Some(c) => (c.min(n), c.min(n)),
|
||||
None => (n, 0),
|
||||
@@ -44,9 +46,13 @@ pub fn end(s: &mut Session) -> Result<()> {
|
||||
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
|
||||
// after the layer was added, so it still holds it: re-base it on the document without the
|
||||
// layer, and committing then records no step (and leaves the redo stack alone).
|
||||
// A snapshot taken before the layer was added (the Text on a path tool, which also took
|
||||
// the path layer away) is put back whole instead.
|
||||
let ds = s.ds_mut()?;
|
||||
let doc = ds.doc.clone();
|
||||
if let Some(it) = ds.interaction.as_mut() {
|
||||
if ds.interaction.as_ref().is_some_and(|it| it.doc.node(te.id).is_none()) {
|
||||
s.cancel_interaction()?;
|
||||
} else if let Some(it) = ds.interaction.as_mut() {
|
||||
it.doc = doc;
|
||||
}
|
||||
}
|
||||
|
||||
+138
-3
@@ -36,6 +36,7 @@ pub enum ToolId {
|
||||
Pen,
|
||||
Pencil,
|
||||
Text,
|
||||
TextOnPath,
|
||||
Comment,
|
||||
Eyedropper,
|
||||
ShapeBuilder,
|
||||
@@ -50,7 +51,7 @@ pub enum ToolId {
|
||||
}
|
||||
|
||||
impl ToolId {
|
||||
pub const ALL: [ToolId; 25] = [
|
||||
pub const ALL: [ToolId; 26] = [
|
||||
ToolId::Move,
|
||||
ToolId::Scale,
|
||||
ToolId::Hand,
|
||||
@@ -66,6 +67,7 @@ impl ToolId {
|
||||
ToolId::Pen,
|
||||
ToolId::Pencil,
|
||||
ToolId::Text,
|
||||
ToolId::TextOnPath,
|
||||
ToolId::Comment,
|
||||
ToolId::Eyedropper,
|
||||
ToolId::ShapeBuilder,
|
||||
@@ -94,6 +96,7 @@ impl ToolId {
|
||||
ToolId::Pen => "pen",
|
||||
ToolId::Pencil => "pencil",
|
||||
ToolId::Text => "text",
|
||||
ToolId::TextOnPath => "textOnPath",
|
||||
ToolId::Comment => "comment",
|
||||
ToolId::Eyedropper => "eyedropper",
|
||||
ToolId::ShapeBuilder => "shapeBuilder",
|
||||
@@ -126,6 +129,7 @@ impl ToolId {
|
||||
ToolId::Pen => "Pen",
|
||||
ToolId::Pencil => "Pencil",
|
||||
ToolId::Text => "Text",
|
||||
ToolId::TextOnPath => "Text on a path",
|
||||
ToolId::Comment => "Comment",
|
||||
ToolId::Eyedropper => "Pick color",
|
||||
ToolId::ShapeBuilder => "Shape builder",
|
||||
@@ -277,7 +281,8 @@ impl ToolId {
|
||||
ToolId::Pen => Cursor::Pen(PenCursor::New),
|
||||
ToolId::Comment => Cursor::Comment,
|
||||
ToolId::Eyedropper => Cursor::Eyedropper,
|
||||
ToolId::Move | ToolId::Scale => Cursor::Default,
|
||||
// Text on a path acts only over a path, where it shows the I-beam.
|
||||
ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default,
|
||||
// The other tools make something where they are pressed: shapes, frames, slices, pencil
|
||||
// strokes, a new text (over a text the Text tool shows the I-beam instead).
|
||||
_ => Cursor::Crosshair,
|
||||
@@ -643,6 +648,31 @@ pub(crate) const SNAP_PX: f64 = 5.0;
|
||||
/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a
|
||||
/// double-click made with an unsteady hand doesn't drag what it lands on.
|
||||
const DRAG_SLOP_PX: f64 = 3.0;
|
||||
/// Screen pixels from a path within which the Text on a path tool takes it.
|
||||
const PATH_REACH_PX: f64 = 6.0;
|
||||
|
||||
/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most
|
||||
/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a
|
||||
/// mask hides at `p` and the operands of boolean groups are left out.
|
||||
fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec<NodeId>) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) {
|
||||
return;
|
||||
}
|
||||
if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) {
|
||||
return;
|
||||
}
|
||||
for c in n.paint_order().rev() {
|
||||
let before = out.len();
|
||||
path_candidates(doc, c, p, reach, depth + 1, out);
|
||||
if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) {
|
||||
out.truncate(before);
|
||||
}
|
||||
}
|
||||
if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl).
|
||||
pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> {
|
||||
@@ -996,7 +1026,7 @@ impl Session {
|
||||
let _ = crate::shapebuilder::arrangement(self, &sel);
|
||||
}
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
if t != ToolId::Text && t != ToolId::Move {
|
||||
if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) {
|
||||
ds.text_edit = None;
|
||||
}
|
||||
ds.revision += 1;
|
||||
@@ -1056,6 +1086,7 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
ToolId::Text => self.text_down(ev),
|
||||
ToolId::TextOnPath => self.text_on_path_down(ev),
|
||||
ToolId::Pen => self.pen_down(ev),
|
||||
ToolId::Pencil => {
|
||||
self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] };
|
||||
@@ -1111,6 +1142,9 @@ impl Session {
|
||||
if tool == ToolId::Text && (on_edited || on_text(self)) {
|
||||
cursor = Cursor::Text;
|
||||
}
|
||||
if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) {
|
||||
cursor = Cursor::Text;
|
||||
}
|
||||
let mut hover = None;
|
||||
let mut guides = Vec::new();
|
||||
let mut measure_box = None;
|
||||
@@ -2124,6 +2158,107 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The layer whose path is under `ev` for the Text on a path tool and where along it the press
|
||||
/// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its
|
||||
/// first subpath (the one text follows), not locked and not inside an instance. Layers in front
|
||||
/// whose path is further away don't hide it.
|
||||
pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> {
|
||||
let reach = ev.tol() * PATH_REACH_PX;
|
||||
let near = |id: NodeId| -> Option<(NodeId, f64)> {
|
||||
let n = doc.node(id)?;
|
||||
let shape = matches!(
|
||||
n.kind,
|
||||
NodeKind::Rectangle
|
||||
| NodeKind::Ellipse { .. }
|
||||
| NodeKind::Polygon { .. }
|
||||
| NodeKind::Star { .. }
|
||||
| NodeKind::Line
|
||||
| NodeKind::Vector { .. }
|
||||
);
|
||||
if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) {
|
||||
return None;
|
||||
}
|
||||
let geom = figcraft_doc::geometry::stroke_geometry(n)?;
|
||||
let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?;
|
||||
// Measured on the page (a layer scaled more one way than the other is still reached
|
||||
// where it shows), the start by length along the layer's own path.
|
||||
let world = doc.world_transform(id);
|
||||
let p = ev.p();
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for (i, w) in line.points.windows(2).enumerate() {
|
||||
let (a, b) = (world * w[0], world * w[1]);
|
||||
let d = b - a;
|
||||
let l2 = d.hypot2();
|
||||
let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let dist = (a.lerp(b, u) - p).hypot();
|
||||
if best.is_none_or(|(_, bd)| dist < bd) {
|
||||
let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?);
|
||||
best = Some((la + u * (lb - la), dist));
|
||||
}
|
||||
}
|
||||
let (along, dist) = best?;
|
||||
let len = line.length();
|
||||
(len > 1e-9 && dist <= reach).then_some((id, along / len))
|
||||
};
|
||||
let mut candidates = Vec::new();
|
||||
path_candidates(doc, page, ev.p(), reach, 0, &mut candidates);
|
||||
candidates.into_iter().find_map(near)
|
||||
}
|
||||
|
||||
/// A press with the Text on a path tool: on a path, a new empty text layer goes onto it,
|
||||
/// starting where it was pressed, and typing starts. The text takes the path layer's place,
|
||||
/// geometry, transform, constraints and effects, and its fills when one of them shows; the path
|
||||
/// layer is removed. Ending the edit with nothing typed puts the path back without an undo step
|
||||
/// ([`textedit::end`]). Elsewhere a press does nothing.
|
||||
fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
if let Some(te) = self.ds()?.text_edit.clone() {
|
||||
if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) {
|
||||
return self.text_click(te.id, ev, ev.shift);
|
||||
}
|
||||
textedit::end(self)?;
|
||||
}
|
||||
let ds = self.ds()?;
|
||||
let (doc, page) = (ds.doc.clone(), ds.page);
|
||||
let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) };
|
||||
let Some(pn) = doc.node(path) else { return Ok(()) };
|
||||
let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) };
|
||||
let index = doc.index_in_parent(path);
|
||||
self.begin_interaction("Text on a path")?;
|
||||
let ds = self.ds_mut()?;
|
||||
let d = ds.doc_mut();
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height);
|
||||
n.name = String::new();
|
||||
n.transform = pn.transform;
|
||||
n.constraints = pn.constraints;
|
||||
n.child_layout = pn.child_layout.clone();
|
||||
n.effects = pn.effects.clone();
|
||||
n.styles.effect = pn.styles.effect.clone();
|
||||
// A path drawn with a stroke only has no fill to give: the text gets the usual one.
|
||||
let filled = pn.fills.iter().any(|f| f.visible);
|
||||
if filled {
|
||||
n.fills = pn.fills.clone();
|
||||
n.styles.fill = pn.styles.fill.clone();
|
||||
} else if let Some(fills) = readable_text_fills(d, parent) {
|
||||
n.fills = fills;
|
||||
}
|
||||
let carried = |k: &str| k.starts_with("effects/") || (filled && (k == "fill" || k.starts_with("fills/")));
|
||||
n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let mut tp = TextPath::new(&geom);
|
||||
tp.start = start;
|
||||
t.path = Some(Box::new(tp));
|
||||
}
|
||||
d.insert(n, parent, index)?;
|
||||
d.remove(path)?;
|
||||
ds.selection = vec![id];
|
||||
textedit::begin(self, id, Some(0), true)?;
|
||||
if !self.prefs.keep_tool {
|
||||
self.tool.tool = ToolId::Move;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drag out a new text box: past the drag slop it has the drag's size (at least one line
|
||||
/// tall) and its top-left, and that size is fixed.
|
||||
fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> {
|
||||
|
||||
@@ -1402,6 +1402,7 @@ name = "English"
|
||||
"Text" = "Text"
|
||||
"Text align" = "Text align"
|
||||
"Text content" = "Text content"
|
||||
"Text on a path" = "Text on a path"
|
||||
"Text size" = "Text size"
|
||||
"Text style" = "Text style"
|
||||
"Text styles" = "Text styles"
|
||||
|
||||
@@ -40,6 +40,7 @@ icons! {
|
||||
Pen,
|
||||
Pencil,
|
||||
Text,
|
||||
TextOnPath,
|
||||
Comment,
|
||||
Actions,
|
||||
Chevron,
|
||||
@@ -561,6 +562,12 @@ fn draw(p: &P, icon: Icon) {
|
||||
p.line(11.5, 3.0, 11.5, 5.5);
|
||||
p.line(5.5, 12.5, 9.5, 12.5);
|
||||
}
|
||||
TextOnPath => {
|
||||
// A small T above the curve it follows.
|
||||
p.line(4.5, 3.0, 11.5, 3.0);
|
||||
p.line(8.0, 3.0, 8.0, 8.5);
|
||||
p.poly(&[(2.0, 13.5), (5.0, 11.5), (8.0, 10.8), (11.0, 11.5), (14.0, 13.5)], false);
|
||||
}
|
||||
Comment => {
|
||||
let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect();
|
||||
b.push((2.5, 13.5));
|
||||
|
||||
@@ -70,7 +70,7 @@ const GROUPS: &[Group] = &[
|
||||
tr_noop("Drawing tools"),
|
||||
&[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)],
|
||||
),
|
||||
Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]),
|
||||
Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text), (ToolId::TextOnPath, Icon::TextOnPath)]),
|
||||
Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]),
|
||||
];
|
||||
|
||||
|
||||
+6
-1
@@ -926,7 +926,12 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap
|
||||
them, `text.removeFromPath {ids?}` makes it plain auto-width text again. Resizing the layer
|
||||
scales the path. The text stays editable (`text.edit.*`, `type_text`); `get_node` shows
|
||||
`textPath {d (SVG path data in the layer's space), start, flip}`. Exports draw it as outlines
|
||||
(PDF too: not selectable); underline and strikethrough aren't drawn on a path.
|
||||
(PDF too: not selectable); underline and strikethrough aren't drawn on a path. With the tool
|
||||
`tool.textOnPath`, a `pointer` press within 6 screen pixels of a shape's or vector's first
|
||||
subpath puts a new, empty text layer on it, starting at the press, and starts typing: the text
|
||||
also takes the path layer's constraints and effects, and its fills when one of them shows. Ending
|
||||
the edit with nothing typed puts the path layer back with no undo step. Locked layers and layers
|
||||
inside instances are skipped.
|
||||
- **Pattern fills**: `fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex|
|
||||
verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?}` makes the fill at
|
||||
`index` (default the top one) the source layer repeated, at `scale` × its own size with `spacing`
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user