mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-13 23:17:42 +00:00
feat(text): Edit type on a path along the curve
The Type tool's clicks, drags and double-clicks on type on a path went to the character at the same spot of the straightened line, and the caret and highlight were drawn straight. A click now finds the character where its nearest point along the path is (compose::PathTextMap, from the line to the path and back, as path_glyphs places the glyphs); the caret stands across the path there and the highlight bends with the text. A click on the letters, above the path, hits the frame. Depends on #636 (PathWarp::nearest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cded7d997e
commit
f96895f400
6 files changed
+135
-3
No files matched your search
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-12** · Editing type on a path: the Type tool clicks, drags and double-clicks along the curve (the character under the pointer, not one on the straightened line), a click on the letters themselves hits the frame, and the caret and highlight bend with the path. No readiness percentage change.
|
||||
- **2026-10-12** · Type on a path has start and end brackets, as in InDesign: selected path type shows them across the path, and dragging either with the Selection or Direct Selection tool moves where the text starts or ends (text past the end bracket is overset). `type.pathOptions {end}`; IDML reads and writes EndBracket. No readiness percentage change.
|
||||
- **2026-10-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
|
||||
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
|
||||
|
||||
@@ -3166,6 +3166,51 @@ pub fn adjacent_row(l: &Line, x: f64, caret_y: f64, up: bool) -> Option<f64> {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Type on a path: between the composed line (x along it, y as composed) and the frame's path
|
||||
/// space, as `path_glyphs` places the glyphs.
|
||||
pub struct PathTextMap {
|
||||
warp: designcraft_geom::warp::PathWarp,
|
||||
start: f64,
|
||||
/// Line y that sits on the path (the alignment), and the line's baseline.
|
||||
on: f64,
|
||||
baseline: f64,
|
||||
}
|
||||
|
||||
impl PathTextMap {
|
||||
/// The map of `item` (type on a path) for its composed frame `ft`; None for other frames.
|
||||
pub fn new(item: &designcraft_doc::Item, ft: &FrameText) -> Option<Self> {
|
||||
let pt = item.text_frame()?.options.path.as_ref()?;
|
||||
let warp = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), pt.flip);
|
||||
let (on, baseline) = match ft.lines.first() {
|
||||
Some(l) => (
|
||||
match pt.align {
|
||||
designcraft_doc::PathAlign::Baseline => l.baseline,
|
||||
designcraft_doc::PathAlign::Ascender => l.baseline - l.ascent,
|
||||
designcraft_doc::PathAlign::Descender => l.baseline + l.descent,
|
||||
designcraft_doc::PathAlign::Center => l.baseline - (l.ascent - l.descent) / 2.0,
|
||||
},
|
||||
l.baseline,
|
||||
),
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
Some(PathTextMap { warp, start: pt.start, on, baseline })
|
||||
}
|
||||
|
||||
/// A point of the composed line, on the path: along it by x, across it by y.
|
||||
pub fn to_path(&self, p: Point) -> Point {
|
||||
match self.warp.at(self.start + p.x) {
|
||||
Some((q, a)) => q + designcraft_geom::Vec2::new(-a.sin(), a.cos()) * (p.y - self.on),
|
||||
None => p,
|
||||
}
|
||||
}
|
||||
|
||||
/// A point of the path space, on the composed line: how far along the path its nearest point
|
||||
/// is, at the baseline (for hit testing).
|
||||
pub fn to_line(&self, p: Point) -> Point {
|
||||
Point::new(self.warp.nearest(p) - self.start, self.baseline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Each visible glyph of a type-on-a-path frame placed on the path: its outline in frame space
|
||||
/// (grouped by run style).
|
||||
pub fn path_glyphs(
|
||||
|
||||
@@ -416,5 +416,11 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
|
||||
if area && it.path.is_closed() && designcraft_geom::hit::fill_contains(&bp, designcraft_geom::FillRule::NonZero, inner) {
|
||||
return true;
|
||||
}
|
||||
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol, inner)
|
||||
// Type on a path: the letters stand over the path, so a click on them hits it too.
|
||||
let reach = if it.text_frame().is_some_and(|t| t.options.path.is_some()) { PATH_TYPE_REACH } else { 0.0 };
|
||||
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner)
|
||||
}
|
||||
|
||||
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
|
||||
/// either side (the type may sit on or under the path).
|
||||
const PATH_TYPE_REACH: f64 = 12.0;
|
||||
@@ -214,4 +214,58 @@ mod tests {
|
||||
let path = |d: &designcraft_doc::Document| d.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.path.clone();
|
||||
assert_eq!(path(&back), path(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_type_tool_clicks_and_selects_along_a_curved_path() {
|
||||
for flip in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// An arch: up from (100, 400) over (300, 200) and down to (500, 400).
|
||||
let id = s
|
||||
.execute("path.create", &json!({"anchors": [{"p": [100, 400], "out": [100, 250]}, {"p": [300, 200], "in": [200, 200], "out": [400, 200]}, {"p": [500, 400], "in": [500, 250]}]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let r = s.execute("type.onPath", &json!({"id": id, "text": "AAAA BBBB CCCC DDDD EEEE FFFF GGGG", "flip": flip})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
// Where each glyph sits on the path, from the composed line.
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let it = d.item(designcraft_doc::ItemId(id)).unwrap();
|
||||
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
|
||||
let ft = &cs.frames[0];
|
||||
let map = designcraft_compose::PathTextMap::new(it, ft).unwrap();
|
||||
let l = &ft.lines[0];
|
||||
let glyph = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).unwrap();
|
||||
let on_path = |byte: usize| {
|
||||
let g = glyph(byte);
|
||||
// A little outside the path, as a click near the letter would be.
|
||||
map.to_path(designcraft_geom::Point::new(g.x + g.adv * 0.3, l.baseline - 4.0))
|
||||
};
|
||||
// A click by the first C (byte 10), on the curve: the caret goes there.
|
||||
let p = on_path(10);
|
||||
let r = s.execute("text.placeCaret", &json!({"frame": id, "point": [p.x, p.y]})).unwrap();
|
||||
assert_eq!(r["pos"], 10, "flip={flip}: {r}");
|
||||
// Dragging to the first E (byte 20) selects C to D.
|
||||
let q = on_path(20);
|
||||
s.execute("text.extendTo", &json!({"frame": id, "point": [q.x, q.y]})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!(t.range(), 10..20, "flip={flip}");
|
||||
// A double-click on a word (its first click places the caret) selects it.
|
||||
let w = on_path(16);
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
|
||||
s.execute("text.selectWord", &json!({"frame": id, "point": [w.x, w.y]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 15..19, "flip={flip}");
|
||||
// A click on a letter, above the path, hits the frame (the type tool's hit test).
|
||||
let above = map.to_path(designcraft_geom::Point::new(glyph(10).x, l.baseline - l.ascent * 0.7));
|
||||
let sp = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(
|
||||
sp.hit_item(0, sp.parent_xf(&sp.find(designcraft_doc::ItemId(id)).unwrap()) * above, 1.0),
|
||||
Some(designcraft_doc::ItemId(id)),
|
||||
"flip={flip}"
|
||||
);
|
||||
// The map runs both ways along the path.
|
||||
let back = map.to_line(map.to_path(designcraft_geom::Point::new(123.0, l.baseline)));
|
||||
assert!((back.x - 123.0).abs() < 0.01, "{back:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +279,8 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
|
||||
let inner = xf.inverse() * pt;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
|
||||
// Type on a path: the click lands on the line where it is along the path.
|
||||
let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner));
|
||||
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
|
||||
return Some((sid, b, Some(CellAddr { table, row, col })));
|
||||
}
|
||||
|
||||
@@ -979,6 +979,28 @@ fn draw_text_selection(
|
||||
if range.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Type on a path: the highlight bends with the text.
|
||||
if let Some(map) = compose::PathTextMap::new(it, ft) {
|
||||
for l in &ft.lines {
|
||||
let (s, e) = (range.start.max(l.range.start), range.end.min(l.range.end));
|
||||
if s >= e {
|
||||
continue;
|
||||
}
|
||||
for q in compose::highlight_quads(l, s, e, false) {
|
||||
let (x0, x1) = (q[0].x.min(q[1].x), q[0].x.max(q[1].x));
|
||||
let (y0, y1) = (q[0].y.min(q[2].y), q[0].y.max(q[2].y));
|
||||
// Short slices, each turned with the path.
|
||||
let n = ((x1 - x0) / 2.0).ceil().clamp(1.0, 2000.0) as usize;
|
||||
for k in 0..n {
|
||||
let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64);
|
||||
let poly =
|
||||
[Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p)));
|
||||
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut quads = Vec::new();
|
||||
let mut glyphs = Vec::new();
|
||||
for (li, l) in ft.lines.iter().enumerate() {
|
||||
@@ -1010,8 +1032,10 @@ fn draw_text_selection(
|
||||
let m = a * doc.text_xf(it);
|
||||
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
|
||||
if blink {
|
||||
let p0 = xf.to_screen(m * Point::new(x, bl - asc));
|
||||
let p1 = xf.to_screen(m * Point::new(x, bl + desc));
|
||||
// Type on a path: the caret stands across the path where it is along it.
|
||||
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
|
||||
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
|
||||
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
|
||||
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
|
||||
}
|
||||
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
|
||||
|
||||
Reference in new issue
Block a user