mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Part of #2572: clicking an area type's out port threads its text into a path or a new frame
This commit is contained in:
@@ -21,6 +21,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_multi,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.add",
|
||||
"Thread Text",
|
||||
[],
|
||||
None,
|
||||
"{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type `from` (a click on its out port) into one more frame, put after it in its thread: closed path or area type `to`, a new rectangle `rect`, or a new frame of `from`'s shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected",
|
||||
has_doc,
|
||||
add
|
||||
),
|
||||
cmd!(
|
||||
"text.thread.releaseSelection",
|
||||
"Release Selection",
|
||||
@@ -205,6 +214,90 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": out.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// `text.thread.add`: the out port's loaded text, placed.
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.thread.add";
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type"))?;
|
||||
let style = src.first_style();
|
||||
// The frame to thread into: an existing object (`to`), or a new one beside `from`.
|
||||
let (to, new) = match (given("to"), given("rect"), given("at")) {
|
||||
(Some(v), None, None) => {
|
||||
let to = checked_id(s, v, C)?;
|
||||
let parent = d.parent_of(to).and_then(|i| d.node(i));
|
||||
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some());
|
||||
if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {
|
||||
return Err(bad(C, "`to` must be a closed path or area type other than `from`"));
|
||||
}
|
||||
if d.text_threads.iter().any(|t| t.contains(&to)) {
|
||||
return Err(bad(C, "`to` is already threaded"));
|
||||
}
|
||||
(to, None)
|
||||
}
|
||||
(None, Some(v), None) => {
|
||||
let [x, y, w, h] = finite_numbers(v)
|
||||
.filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0)
|
||||
.ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?;
|
||||
let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h));
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame, vectorcraft_geom::Affine::translate((x, y)))))
|
||||
}
|
||||
(None, None, Some(v)) => {
|
||||
let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
||||
let TextKind::Area { frame } = &src.kind else { return Err(bad(C, "`from` must be area type")) };
|
||||
let b = frame.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?;
|
||||
let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf;
|
||||
(NodeId(0), Some(empty_frame(src, style.clone(), frame.clone(), xf)))
|
||||
}
|
||||
_ => return Err(bad(C, "give one of `to`, `rect` or `at`")),
|
||||
};
|
||||
let appearance = d.node(from).map(|n| n.appearance.clone());
|
||||
let id = s.edit("Thread Text", |d, sel| {
|
||||
let to = match new {
|
||||
Some(t) => {
|
||||
let (parent, idx, _) = d.position(from).ok_or(EngineError::NoNode(from))?;
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Text(Box::new(t)));
|
||||
if let Some(a) = appearance {
|
||||
n.appearance = a;
|
||||
}
|
||||
d.insert(parent, idx + 1, n)?;
|
||||
id
|
||||
}
|
||||
None => {
|
||||
if text_of(d, to).is_none() {
|
||||
let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?;
|
||||
*d.node_mut(to).ok_or(EngineError::NoNode(to))? = frame_from_path(&n, style).ok_or_else(|| bad(C, "not a closed path"))?;
|
||||
}
|
||||
to
|
||||
}
|
||||
};
|
||||
match d.text_threads.iter_mut().find(|t| t.contains(&from)) {
|
||||
Some(t) => {
|
||||
let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1);
|
||||
t.insert(at, to);
|
||||
}
|
||||
None => d.text_threads.push(vec![from, to]),
|
||||
}
|
||||
// `to` is new or changed, so [`reflow`] flows the story into it after this edit.
|
||||
sel.set([from, to]);
|
||||
Ok(to)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// An empty area-type frame `frame` placed by `xf`, with the type options of `src`.
|
||||
fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, frame: vectorcraft_geom::PathData, xf: vectorcraft_geom::Affine) -> TextObject {
|
||||
let mut t = src.clone();
|
||||
t.runs = vec![TextRun { text: String::new(), style, inline: None }];
|
||||
t.set_all_paras(src.para_at(0).clone());
|
||||
t.kind = TextKind::Area { frame };
|
||||
t.xf = xf;
|
||||
refresh_bounds(&mut t);
|
||||
t
|
||||
}
|
||||
|
||||
/// Give a node a fresh allocation so [`reflow`] sees its thread as changed.
|
||||
fn touch(d: &mut Document, id: NodeId) {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
@@ -413,6 +506,80 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_id(v: Value) -> u64 {
|
||||
v["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn threads(s: &Session) -> Vec<Vec<u64>> {
|
||||
s.doc().unwrap().doc.text_threads.iter().map(|t| t.iter().map(|i| i.0).collect()).collect()
|
||||
}
|
||||
|
||||
/// Bounds of area type `id`'s frame on the page.
|
||||
fn frame_box(s: &Session, id: u64) -> vectorcraft_geom::Rect {
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("not area type") };
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("not area type") };
|
||||
t.xf.transform_rect_bbox(frame.bounds().unwrap())
|
||||
}
|
||||
|
||||
/// #2572: the text loaded from an out port threads into a closed path, into a new frame of the
|
||||
/// first one's shape and size at a point, and into a new rectangle, each in one undo step.
|
||||
#[test]
|
||||
fn add_threads_into_a_path_a_frame_at_a_point_and_a_rectangle() {
|
||||
let (mut s, a, b) = setup();
|
||||
assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap()), b);
|
||||
assert_eq!(threads(&s), [[a, b]]);
|
||||
assert!(!text(&s, b).is_empty(), "the overflow flowed into the path");
|
||||
assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(b)]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(threads(&s).is_empty());
|
||||
assert!(matches!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().kind, NodeKind::Path { .. }), "undo gives the path back");
|
||||
|
||||
let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200]})).unwrap());
|
||||
assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the first frame's size, its top-left at the click");
|
||||
let d = new_id(s.execute("text.thread.add", &json!({"from": c, "rect": [10, 100, 300, 150]})).unwrap());
|
||||
assert_eq!(frame_box(&s, d), vectorcraft_geom::Rect::new(10.0, 100.0, 310.0, 250.0));
|
||||
assert_eq!(threads(&s), [[a, c, d]], "each new frame comes after the one it was threaded from");
|
||||
assert_eq!(format!("{}{}{}", text(&s, a), text(&s, c), text(&s, d)), TEXT.repeat(4));
|
||||
assert!(!text(&s, d).is_empty());
|
||||
// A frame threaded from the middle of a thread goes in after it.
|
||||
let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0]})).unwrap());
|
||||
assert_eq!(threads(&s), [[a, e, c, d]]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(e)).is_none());
|
||||
assert_eq!(threads(&s), [[a, c, d]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_refuses_bad_params_and_leaves_the_document_alone() {
|
||||
let (mut s, a, b) = setup();
|
||||
let line = new_id(s.execute("shape.line", &json!({"x1": 0, "y1": 280, "x2": 50, "y2": 290})).unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for p in [
|
||||
json!({"to": b}),
|
||||
json!({"from": b, "at": [0, 0]}),
|
||||
json!({"from": 9999, "at": [0, 0]}),
|
||||
json!({"from": -1, "at": [0, 0]}),
|
||||
json!({"from": a}),
|
||||
json!({"from": a, "to": b, "at": [0, 0]}),
|
||||
json!({"from": a, "to": a}),
|
||||
json!({"from": a, "to": line}),
|
||||
json!({"from": a, "to": 9999}),
|
||||
json!({"from": a, "at": [0]}),
|
||||
json!({"from": a, "at": ["1", 2]}),
|
||||
json!({"from": a, "rect": [0, 0, -10, 10]}),
|
||||
json!({"from": a, "rect": [0, 0, 10, 0]}),
|
||||
json!({"from": a, "rect": [0, 0, 10]}),
|
||||
json!({"from": a, "rect": "big"}),
|
||||
] {
|
||||
assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document");
|
||||
}
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap();
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 200, "text": "x", "area": {"width": 50, "height": 20}})).unwrap());
|
||||
assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_and_remove() {
|
||||
let (mut s, a, b) = setup();
|
||||
|
||||
@@ -1020,3 +1020,34 @@ fn overflow_marker_shows_with_the_selection_tool() {
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert_eq!(red(&mut s), 0, "nothing selected");
|
||||
}
|
||||
|
||||
/// #2572: with the Selection tool, a click on the out port and a click on empty canvas thread the
|
||||
/// overflow into a new frame there, in one undo step; Escape, or another tool, drops the loaded text.
|
||||
#[test]
|
||||
fn clicking_the_out_port_then_the_canvas_threads_the_text_into_a_new_frame() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let long = "Words that fill more than the frame holds. ".repeat(20);
|
||||
let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": long, "size": 20, "area": {"width": 100, "height": 40}})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let port = Point::new(150.0, 90.0 - vectorcraft_tools::text::PORT_RISE_PX);
|
||||
let loaded = |s: &mut Session| s.cursor(Point::new(400.0, 400.0), Mods::default(), v) == vectorcraft_tools::Cursor::LoadedText;
|
||||
click(&mut s, port.x, port.y);
|
||||
assert!(loaded(&mut s));
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert!(!loaded(&mut s), "Escape drops the text");
|
||||
click(&mut s, port.x, port.y);
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(!loaded(&mut s), "another tool drops the text");
|
||||
let steps = undo_labels(&s).len();
|
||||
click(&mut s, port.x, port.y);
|
||||
click(&mut s, 400.0, 400.0);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let [thread] = &doc.text_threads[..] else { panic!("one thread: {:?}", doc.text_threads) };
|
||||
assert_eq!(thread[0], id);
|
||||
assert_eq!(format!("{}{}", obj(&s, id).plain_text(), obj(&s, thread[1]).plain_text()), long);
|
||||
assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]);
|
||||
}
|
||||
|
||||
@@ -126,6 +126,15 @@ shown_when = ["The Selection tool over the type widget of selected type (a doubl
|
||||
hotspot = "The arrow's tip."
|
||||
look = "Arrow with a type badge (a letter T)."
|
||||
|
||||
[[cursor]]
|
||||
id = "LoadedText"
|
||||
group = "Selection"
|
||||
shown_when = [
|
||||
"The Selection tool after a click on the red out port of selected area type whose text overflows: the pointer carries that text until a click threads it into a closed path, a click on empty canvas makes a new frame of the same shape there, or a drag draws the new frame. Escape or another tool drops it.",
|
||||
]
|
||||
hotspot = "Where the new frame's top-left corner goes (the click), or the path clicked."
|
||||
look = "A small block of text lines: the overflow text the pointer carries. Until it is drawn it borrows the I-beam (Text)."
|
||||
|
||||
# ── Pen family ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[cursor]]
|
||||
|
||||
@@ -518,6 +518,9 @@ pub enum Cursor {
|
||||
/// Over the type widget of selected type (a double-click converts point type to area type and
|
||||
/// back): the arrow with a type badge.
|
||||
TypeWidget,
|
||||
/// The Selection tool loaded with the text of an area type's out port (a click threads it
|
||||
/// into a path or a new frame, a drag draws the new frame).
|
||||
LoadedText,
|
||||
/// The Shape Builder: a crosshair with a plus (merge mode)...
|
||||
ShapeBuilder,
|
||||
/// ...or, with Alt held, a minus (erase mode).
|
||||
@@ -667,7 +670,7 @@ impl Cursor {
|
||||
}
|
||||
|
||||
/// The cursor this one borrows until it's drawn: the generic crosshair for the
|
||||
/// tools, the I-beam for vertical type, the Pen's add cursor for the Mesh tool and Puppet
|
||||
/// tools, the I-beam for vertical type and loaded text, the Pen's add cursor for the Mesh tool and Puppet
|
||||
/// Warp, not-allowed for the Scissors away from a path. `None` for a cursor with a look of
|
||||
/// its own.
|
||||
pub fn stand_in(self) -> Option<Self> {
|
||||
@@ -695,7 +698,7 @@ impl Cursor {
|
||||
| Cursor::SymbolScreener
|
||||
| Cursor::SymbolStyler
|
||||
| Cursor::Scissors => Some(Cursor::Crosshair),
|
||||
Cursor::TypeVertical => Some(Cursor::Text),
|
||||
Cursor::TypeVertical | Cursor::LoadedText => Some(Cursor::Text),
|
||||
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
|
||||
Cursor::ScissorsNotAllowed => Some(Cursor::NotAllowed),
|
||||
_ => None,
|
||||
|
||||
+146
-1
@@ -16,6 +16,10 @@
|
||||
//! scaling its type: the text reflows at its size.
|
||||
//! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and
|
||||
//! E pressed again makes it take the objects it touches.
|
||||
//! A click on the red out port of selected area type whose text overflows loads the pointer with
|
||||
//! that text ([`Cursor::LoadedText`]): the next click threads it into the closed path or area type
|
||||
//! under the pointer, or into a new frame of the same shape and size at the click on empty canvas,
|
||||
//! and a drag draws a rectangle for the new frame (`text.thread.add`). Escape drops the loaded text.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{hit_test, marquee, objects_at};
|
||||
@@ -73,6 +77,12 @@ enum State {
|
||||
Pie(PieDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
/// Loaded with the text of area type `from` by a click on its out port; `press`: where the
|
||||
/// next press went down and where the pointer is now.
|
||||
Loaded {
|
||||
from: NodeId,
|
||||
press: Option<(Point, Point)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -257,6 +267,45 @@ impl SelectionTool {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The pointer loaded with the text of area type `from` ([`State::Loaded`]): a press starts
|
||||
/// placing it, and its release threads it (`text.thread.add`). None when `from` is no longer
|
||||
/// area type: the press is an ordinary one.
|
||||
fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, press: Option<(Point, Point)>) -> Option<Vec<Action>> {
|
||||
let p = ev.pos;
|
||||
match (ev.kind, press) {
|
||||
(PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_area_type) => {
|
||||
self.state = State::Idle;
|
||||
return None;
|
||||
}
|
||||
(PointerKind::Down, _) => self.state = State::Loaded { from, press: Some((p, p)) },
|
||||
(PointerKind::Drag, Some((start, _))) => self.state = State::Loaded { from, press: Some((start, p)) },
|
||||
(PointerKind::Up, Some((start, _))) => {
|
||||
self.state = State::Idle;
|
||||
return Some(vec![Action::Exec("text.thread.add".into(), thread_params(cx, from, start, p))]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the text of `from` goes for a press at `start` released at `end`: a dragged rectangle,
|
||||
/// else the closed path or area type pressed on, else a new frame with its top-left at `start`.
|
||||
fn thread_params(cx: &ToolContext, from: NodeId, start: Point, end: Point) -> Value {
|
||||
let r = Rect::from_points(start, end);
|
||||
if r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0) {
|
||||
return json!({ "from": from.0, "rect": [r.x0, r.y0, r.width(), r.height()] });
|
||||
}
|
||||
let frame = |n: &vectorcraft_doc::Node| {
|
||||
is_area_type(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed))
|
||||
};
|
||||
let to =
|
||||
hit_test(cx.doc, start, cx.hit_options()).map(|h| h.top_object(cx.isolation)).filter(|id| *id != from && cx.doc.node(*id).is_some_and(frame));
|
||||
match to {
|
||||
Some(to) => json!({ "from": from.0, "to": to.0 }),
|
||||
None => json!({ "from": from.0, "at": [start.x, start.y] }),
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for SelectionTool {
|
||||
@@ -277,11 +326,19 @@ impl Tool for SelectionTool {
|
||||
*enclosed = !*enclosed;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape && matches!(self.state, State::Loaded { .. }) {
|
||||
self.state = State::Idle;
|
||||
return vec![];
|
||||
}
|
||||
if key == ToolKey::Escape { self.guide.cancel() } else { vec![] }
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
key == ToolKey::Letter('e') && matches!(self.state, State::Marquee { .. })
|
||||
match self.state {
|
||||
State::Marquee { .. } => key == ToolKey::Letter('e'),
|
||||
State::Loaded { .. } => key == ToolKey::Escape,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn transforming(&self) -> bool {
|
||||
@@ -294,6 +351,11 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let State::Loaded { from, press } = self.state
|
||||
&& let Some(out) = self.loaded(cx, ev, from, press)
|
||||
{
|
||||
return out;
|
||||
}
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
}
|
||||
@@ -337,6 +399,11 @@ impl Tool for SelectionTool {
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
// 0. The out port of selected area type: the pointer takes its text.
|
||||
if let Some(from) = crate::text::out_port_at(cx, p) {
|
||||
self.state = State::Loaded { from, press: None };
|
||||
return vec![];
|
||||
}
|
||||
// 1. Live Corners widgets, pie widgets and type on a path's brackets, then the
|
||||
// bounding-box handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||||
@@ -542,6 +609,7 @@ impl Tool for SelectionTool {
|
||||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||||
State::Pie(d) => o.extend(d.overlays()),
|
||||
State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
_ => {}
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
@@ -569,6 +637,7 @@ impl Tool for SelectionTool {
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
State::Pie(_) => return Cursor::ArrowHollow,
|
||||
State::Bracket(_) => return Cursor::PathBracket,
|
||||
State::Loaded { .. } => return Cursor::LoadedText,
|
||||
_ => {}
|
||||
}
|
||||
if self.guide.busy() {
|
||||
@@ -1537,4 +1606,80 @@ mod tests {
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
||||
}
|
||||
|
||||
/// [`doc_with_area_type`] with more text than its frame holds, selected; the rectangle's id and
|
||||
/// where the frame's out port is.
|
||||
fn overflowing_area_type() -> (vectorcraft_doc::Document, NodeId, NodeId, Point) {
|
||||
let (mut d, id) = doc_with_area_type();
|
||||
let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id;
|
||||
let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
|
||||
let style = vectorcraft_doc::CharStyle { size: 20.0, ..Default::default() };
|
||||
t.runs = vec![vectorcraft_doc::TextRun::new("Words that fill more than the frame holds. ".repeat(20), style)];
|
||||
// On the frame's right side, above its bottom-right corner (120 × 40 at 100%).
|
||||
let port = t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX);
|
||||
(d, id, rect, port)
|
||||
}
|
||||
|
||||
/// #2572: a click on the out port loads the pointer with the text; a click on empty canvas then
|
||||
/// threads it into a new frame there, a click on a closed path into the path, a drag into the
|
||||
/// rectangle it draws.
|
||||
#[test]
|
||||
fn a_click_on_the_out_port_loads_the_text_and_the_next_click_or_drag_threads_it() {
|
||||
let (d, id, rect, port) = overflowing_area_type();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)];
|
||||
let load = |t: &mut SelectionTool| {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty());
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty());
|
||||
assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
|
||||
};
|
||||
load(&mut t);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0]})));
|
||||
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText, "the text is placed");
|
||||
load(&mut t);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0})));
|
||||
load(&mut t);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0));
|
||||
assert!(t.overlays(&cx).contains(&Overlay::Marquee(Rect::new(20.0, 20.0, 80.0, 90.0))), "the new frame's outline");
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0]})));
|
||||
// The bounding box's corner handle below the port still resizes the frame.
|
||||
let corner = Point::new(port.x, port.y + crate::text::PORT_RISE_PX);
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]);
|
||||
}
|
||||
|
||||
/// Escape drops the loaded text, and the next click is an ordinary one; type that fits its
|
||||
/// frame shows no out port to click.
|
||||
#[test]
|
||||
fn escape_drops_the_loaded_text_and_type_that_fits_has_no_port() {
|
||||
let (d, id, _, port) = overflowing_area_type();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y));
|
||||
t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y));
|
||||
assert!(t.claims_key(&cx, ToolKey::Escape), "Escape is the loaded pointer's");
|
||||
assert!(t.key(&cx, ToolKey::Escape, Mods::default()).is_empty());
|
||||
assert!(!t.claims_key(&cx, ToolKey::Escape));
|
||||
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
||||
|
||||
let (d, id) = doc_with_area_type();
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
let cx = crate::testutil::cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y));
|
||||
t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y));
|
||||
assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,16 +311,32 @@ impl TypeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit): at the
|
||||
/// bottom right of the frame, at the end of the path for type on a path.
|
||||
fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
let port = match &t.kind {
|
||||
TextKind::Area { frame } => frame.bounds().map(|b| t.xf * Point::new(b.x1, b.y1)),
|
||||
/// How far (screen px) the out port of area type sits above the bottom-right corner of its frame,
|
||||
/// on its right side: clear of the bounding box's corner handle, so both can be grabbed.
|
||||
pub const PORT_RISE_PX: f64 = 12.0;
|
||||
/// Half the out port's side (screen px).
|
||||
const PORT_HALF_PX: f64 = 4.5;
|
||||
|
||||
/// Centre of the out port of `t`: on the right side of an area type frame just above its
|
||||
/// bottom-right corner ([`PORT_RISE_PX`]), at the end of the path for type on a path.
|
||||
fn out_port(t: &TextObject, cx: &ToolContext) -> Option<Point> {
|
||||
match &t.kind {
|
||||
TextKind::Area { frame } => {
|
||||
let b = frame.bounds()?;
|
||||
let corner = t.xf * Point::new(b.x1, b.y1);
|
||||
let up = t.xf * Point::new(b.x1, b.y0) - corner;
|
||||
let len = up.hypot();
|
||||
Some(if len > 1e-9 && len.is_finite() { corner + up * (cx.tol(PORT_RISE_PX).min(len / 2.0) / len) } else { corner })
|
||||
}
|
||||
TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)),
|
||||
TextKind::Point => None,
|
||||
};
|
||||
let Some(c) = port else { return };
|
||||
let h = cx.tol(4.5);
|
||||
}
|
||||
}
|
||||
|
||||
/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit), at [`out_port`].
|
||||
fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
let Some(c) = out_port(t, cx) else { return };
|
||||
let h = cx.tol(PORT_HALF_PX);
|
||||
let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h);
|
||||
let red = [230, 30, 30];
|
||||
o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false });
|
||||
@@ -329,6 +345,20 @@ fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec<Overlay>) {
|
||||
o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false });
|
||||
}
|
||||
|
||||
/// The selected area type whose red out port is under `p` (only text that overflows shows one):
|
||||
/// a click there loads the Selection tool with its text, to thread into another frame.
|
||||
pub(crate) fn out_port_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
||||
let h = cx.tol(PORT_HALF_PX + 1.0);
|
||||
let near = |t: &TextObject| out_port(t, cx).is_some_and(|c| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h);
|
||||
cx.selection.objects.iter().copied().find(|id| {
|
||||
cx.doc
|
||||
.node(*id)
|
||||
.filter(|n| crate::select::is_area_type(n))
|
||||
.and_then(|_| TypeTool::text(cx, *id))
|
||||
.is_some_and(|t| near(t) && vectorcraft_text::layout(FontDb::global(), t).overflow)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept
|
||||
/// with the object and the fonts it was made from, as overlays run every frame.
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -665,7 +665,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
| Cursor::Scissors
|
||||
| Cursor::ScissorsNotAllowed
|
||||
| Cursor::MeshAdd
|
||||
| Cursor::PuppetPin => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
| Cursor::PuppetPin
|
||||
| Cursor::LoadedText => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2127,8 +2128,9 @@ impl HiddenMarks {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text threads of the selected frames: a line from each frame's out port (bottom right) to the
|
||||
/// next frame's in port (top left), like Illustrator's thread indicators.
|
||||
/// Text threads of the selected frames: a line from each frame's out port (on its right side,
|
||||
/// just above the bottom right, where the tools draw the red one) to the next frame's in port (top
|
||||
/// left), like Illustrator's thread indicators.
|
||||
fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
for thread in &st.doc.text_threads {
|
||||
@@ -2138,7 +2140,8 @@ fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let color = c32(st.doc.layer_color(thread[0]));
|
||||
let frames: Vec<vectorcraft_geom::Rect> = thread.iter().filter_map(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds())).collect();
|
||||
for w in frames.windows(2) {
|
||||
let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1)), xf.to_screen(Point::new(w[1].x0, w[1].y0)));
|
||||
let rise = (vectorcraft_tools::text::PORT_RISE_PX / xf.zoom.max(1e-9)).min(w[0].height() / 2.0);
|
||||
let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1 - rise)), xf.to_screen(Point::new(w[1].x0, w[1].y0)));
|
||||
p.line_segment([out, inp], Stroke::new(1.0, color));
|
||||
for port in [out, inp] {
|
||||
p.rect_filled(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Color32::WHITE);
|
||||
|
||||
@@ -409,7 +409,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
|
||||
|
||||
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
|
||||
#[cfg(test)]
|
||||
const GLYPHS: [Cursor; 74] = [
|
||||
const GLYPHS: [Cursor; 75] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
@@ -456,6 +456,7 @@ const GLYPHS: [Cursor; 74] = [
|
||||
Cursor::BlendAnchor,
|
||||
Cursor::PathBracket,
|
||||
Cursor::TypeWidget,
|
||||
Cursor::LoadedText,
|
||||
Cursor::ShapeBuilder,
|
||||
Cursor::ShapeBuilderErase,
|
||||
Cursor::Lasso,
|
||||
|
||||
+22
@@ -3374,6 +3374,28 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print
|
||||
{"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}}
|
||||
```
|
||||
|
||||
## Threading text from the out port
|
||||
|
||||
As in the reference app, a click with the Selection tool on the red "+" out port of selected area type whose text
|
||||
overflows loads the pointer with that text (the port sits on the frame's right side, just above the bottom-right
|
||||
corner, so the corner handle stays free). The next click threads the text into the closed path or area type under the
|
||||
pointer, which becomes the next frame; a click on empty canvas makes a new frame of the first one's shape and size
|
||||
with its top-left corner at the click, and a drag draws a rectangle for the new frame. Escape or another tool drops
|
||||
the loaded text. Each placement is one undo step (`text.thread.add`), which agents can run directly:
|
||||
|
||||
`text.thread.add {from, to? | rect?: [x, y, width, height] | at?: [x, y]}` threads area type `from` into one more
|
||||
frame, put right after it in its thread (a new thread of the two when `from` has none): `to`, a closed path (converted
|
||||
to an empty frame) or area type that is not threaded yet; `rect`, a new rectangular frame (positive width and height);
|
||||
or `at`, a new frame of `from`'s shape and size whose top-left is at `at`. Give exactly one of the three. New frames
|
||||
take `from`'s type options, paint and first character and paragraph attributes, and go just above it. The story
|
||||
reflows through the thread, the two frames are selected, and it returns `{id}`, the frame threaded into. It refuses
|
||||
an `id` that doesn't exist, a `from` that isn't area type (type on a path doesn't thread), an open path, `from`
|
||||
itself, an object inside a compound path, a frame already in a thread, and coordinates that aren't finite numbers.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"text.thread.add","params":{"from":42,"at":[300,120]}}}
|
||||
```
|
||||
|
||||
## Converting between point type and area type
|
||||
|
||||
With the Selection tool, a single selected point or area type object shows the type widget: a small circle beside
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Type parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (a click on the out port threads the text into a closed path, a new frame at the click or a dragged rectangle, #2572 in part; scores unchanged; native family labels fall back to localized legacy names when typographic names are English-only, #2208; scores unchanged; Cmd+Shift+C aligns centre off the Mac, #1940; tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966; Cmd+Shift+C aligns centre off the Mac, a double-click on area type's bottom handle turns on Auto Size, #1940; MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged; Vertical fullwidth question and exclamation marks keep their nominal glyph upright when the font has no vertical alternate; scores and hours unchanged. tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
|
||||
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
|
||||
@@ -22,6 +22,7 @@ against Illustrator's public documentation; **estimated** unless noted.
|
||||
| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands |
|
||||
| Area Type Options (rows, columns, inset, first baseline, Auto Size, Shrink Text to Fit) | done | the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
|
||||
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444) |
|
||||
| Threading from the out port: a click on the red "+" loads the text; a click on a closed path or area type threads into it, a click on empty canvas makes a frame of the same shape and size there, a drag draws a rectangular frame; Escape or another tool cancels | partial | #2572 in part: the Selection tool, `text.thread.add {from, to?, rect?, at?}`; only the red port of overflowing area type is clickable (empty ports and in ports are not drawn, so they can't be clicked yet), type on a path doesn't thread, and the loaded cursor borrows the I-beam until it's drawn |
|
||||
| Type on a Path effects and options; brackets | done | #429 |
|
||||
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing |
|
||||
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
|
||||
@@ -60,6 +61,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | A click on area type's red out port loads the Selection tool with the overflow text; the next click threads it into a closed path or a new frame of the same shape there, a drag into a new rectangle (`text.thread.add`, #2572 in part). The out port moved up the frame's right side, clear of the corner handle. Scores and hours unchanged. |
|
||||
| 2026-10-16 | minor | Native font-family labels fall back from English-only typographic names to localized legacy names (#2208). English labels and native typographic precedence are preserved; synthetic loaded/catalog tests. Scores and estimates unchanged. |
|
||||
| 2026-10-16 | minor | Align Stroke on live type: the characters' stroke goes inside or outside the glyphs without creating outlines, for every run or a range; kept in the native file and in canvas, PDF, EPS and SVG output (#2294); beyond Illustrator, scores and hours unchanged |
|
||||
| 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy (#1940); scores and hours unchanged |
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user