Merge pull request #2134 from dotCooCoo/feat/free-distort-dialog-corners

Effects: Free Distort's corners are dragged in its dialog
This commit is contained in:
BFlat authored and GitHub committed 2026-10-16 04:20:09 +07:00
commit cf0be249ba
9 files changed
+330 -2

No files matched your search

+5 -1
View File
@@ -11,7 +11,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_color::Color;
use super::{DialogSpec, form, transform_each};
use super::{DialogSpec, form, free_distort, transform_each};
use crate::panels::c32;
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -141,6 +141,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()),
None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()),
None if id == "artistic.filmGrain" => film_grain_fields(ui, d),
None if id == free_distort::FREE_DISTORT => {
free_distort::fields(app, ui, d);
values_changed(d)
}
None => {
let doc = vectorcraft_effects::effect_info(&id).map(|e| e.params).unwrap_or_default();
let unit = app.session.general_unit();
+172
View File
@@ -0,0 +1,172 @@
//! Free Distort's dialog body (Effect › Distort & Transform › Free Distort…): a box in the
//! proportions of the first selected object's bounds with a handle on each of its four corners.
//! Dragging a handle moves that corner. A handle focused by a click or Tab moves with the arrow
//! keys, and ten times as far with Shift. Reset puts every corner back on the box. The corners are
//! the effect's `corners` field, which the effect dialog previews and applies.
use egui::{Key, Modifiers, Pos2, Rect, Sense, Stroke, Vec2, vec2};
use serde_json::{Value, json};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The Free Distort effect.
pub(crate) const FREE_DISTORT: &str = "distort.freeDistort";
/// The corners on the box: top left, top right, bottom right and bottom left, in unit box
/// coordinates.
pub(super) const IDENTITY: [[f64; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
/// The area the handles move in.
const PAD: Vec2 = vec2(260.0, 200.0);
/// The space between the box and the edges of the area.
const MARGIN: f32 = 40.0;
/// The drawn size of a handle.
const HANDLE: f32 = 9.0;
/// The size of the square that takes a press on a handle.
const GRAB: f32 = 16.0;
/// How far an arrow key moves a corner, as a share of the box's side.
const STEP: f64 = 0.01;
/// The handles' names for screen readers, in the order of the corners.
fn names() -> [&'static str; 4] {
[tl!("Top Left"), tl!("Top Right"), tl!("Bottom Right"), tl!("Bottom Left")]
}
/// The corners in `d.fields["corners"]`. A missing or malformed corner reads as its place on the
/// box, as the effect reads it.
pub(super) fn corners(d: &Dialog) -> [[f64; 2]; 4] {
let mut c = IDENTITY;
if let Some(list) = d.fields.get("corners").and_then(Value::as_array) {
for (slot, v) in c.iter_mut().zip(list) {
if let Some(a) = v.as_array()
&& let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64))
&& x.is_finite()
&& y.is_finite()
{
*slot = [x, y];
}
}
}
c
}
/// The width of the first selected object's bounds over their height, limited to 1/8 to 8; 1 when
/// nothing is selected or the bounds are empty.
fn aspect(app: &VectorcraftApp) -> f32 {
let bounds = app.session.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))).and_then(|n| n.geometric_bounds());
match bounds {
Some(b) if b.width() > 0.0 || b.height() > 0.0 => (b.width().max(1e-9) / b.height().max(1e-9)).clamp(0.125, 8.0) as f32,
_ => 1.0,
}
}
/// A coordinate kept to four decimals (a hundredth of a percent of the box).
fn round(x: f64) -> f64 {
(x * 1e4).round() / 1e4
}
/// The box, the handles and Reset. Changes go to `d.fields["corners"]`.
pub(super) fn fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let before = corners(d);
let mut c = before;
let (pad, _) = ui.allocate_exact_size(PAD, Sense::hover());
let inner = pad.shrink(MARGIN);
let aspect = aspect(app);
let size = if aspect >= inner.width() / inner.height() {
vec2(inner.width(), inner.width() / aspect)
} else {
vec2(inner.height() * aspect, inner.height())
};
let bx = Rect::from_center_size(inner.center(), size);
let to_screen = |[x, y]: [f64; 2]| Pos2::new(bx.left() + x as f32 * bx.width(), bx.top() + y as f32 * bx.height());
let to_unit = |p: Pos2| [f64::from((p.x - bx.left()) / bx.width()), f64::from((p.y - bx.top()) / bx.height())];
let (lo, hi) = (to_unit(pad.min), to_unit(pad.max));
let names = names();
// Per handle: whether it is held or hovered, and whether it has the keyboard focus.
let mut states = [(false, false); 4];
for (i, corner) in c.iter_mut().enumerate() {
let id = ui.id().with(("free-distort-corner", i));
let at = pad.clamp(to_screen(*corner));
let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(GRAB)), id, Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), names[i]));
if resp.drag_started() || resp.clicked() {
resp.request_focus();
}
if resp.drag_started()
&& let Some(origin) = ui.input(|i| i.pointer.press_origin())
{
// The offset from the press to the handle's center, kept for the drag.
ui.data_mut(|m| m.insert_temp(id, at - origin));
}
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
{
let grip: Vec2 = ui.data(|m| m.get_temp(id)).unwrap_or_default();
*corner = to_unit(pad.clamp(p + grip)).map(round);
}
if resp.has_focus() {
ui.memory_mut(|m| {
m.set_focus_lock_filter(id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() })
});
for (key, dx, dy) in [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)] {
// Shift first: a key without modifiers also matches the key with Shift.
for (modifiers, step) in [(Modifiers::SHIFT, STEP * 10.0), (Modifiers::NONE, STEP)] {
if ui.input_mut(|i| i.consume_key(modifiers, key)) {
// A corner stays inside the area, or, when it lies outside, no farther out.
let [x, y] = &mut *corner;
for (v, d, lo, hi) in [(x, dx, lo[0], hi[0]), (y, dy, lo[1], hi[1])] {
*v = round((*v + d * step).clamp(lo.min(*v), hi.max(*v)));
}
}
}
}
}
states[i] = (resp.dragged() || resp.hovered(), resp.has_focus());
if resp.dragged() || resp.hovered() {
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
}
}
ui.add_space(4.0);
let reset = ui
.add_enabled_ui(c != IDENTITY, |ui| {
let r = widgets::flat_button(ui, tl!("Reset"), 64.0);
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset")));
r
})
.inner;
if reset.clicked() {
c = IDENTITY;
}
paint(ui, pad, bx, c.map(to_screen), states);
if c != before {
d.fields.insert("corners".into(), json!(c));
}
}
/// The pad: the box at rest, the distorted box with a grid across it, and the handles (filled in
/// the accent color while focused).
fn paint(ui: &egui::Ui, pad: Rect, bx: Rect, q: [Pos2; 4], states: [(bool, bool); 4]) {
let t = Tokens::get(ui.ctx());
let painter = ui.painter_at(pad);
painter.rect_filled(pad, 2.0, t.input);
painter.rect_stroke(bx, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Middle);
let at = |u: f32, v: f32| q[0].lerp(q[1], u).lerp(q[3].lerp(q[2], u), v);
let faint = Stroke::new(1.0, t.text_dim.gamma_multiply(0.6));
for k in 1..4 {
let f = k as f32 / 4.0;
painter.line_segment([at(f, 0.0), at(f, 1.0)], faint);
painter.line_segment([at(0.0, f), at(1.0, f)], faint);
}
painter.add(egui::Shape::closed_line(q.to_vec(), Stroke::new(1.5, t.text)));
for (p, (active, focused)) in q.into_iter().zip(states) {
let r = Rect::from_center_size(pad.clamp(p), Vec2::splat(HANDLE));
painter.rect_filled(r, 1.0, if focused { t.accent } else { t.panel });
painter.rect_stroke(r, 1.0, Stroke::new(if active { 2.0 } else { 1.0 }, t.text), egui::StrokeKind::Middle);
if focused {
painter.rect_stroke(r.expand(3.0), 2.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Outside);
}
}
painter.rect_stroke(pad, 2.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
}
+1
View File
@@ -35,6 +35,7 @@ pub mod flare_options;
pub mod flatten;
pub mod flattener_presets;
mod form;
pub(crate) mod free_distort;
pub mod freehand;
mod gradient_stop;
pub mod graph_data;
+122
View File
@@ -899,6 +899,128 @@ fn zig_zag_has_sliders_and_radio_buttons() {
assert!(!lines.contains(&"Ridges:"), "{lines:?}");
}
/// Frames of the dialog layer on one context (so a drag and the keyboard focus last from frame to
/// frame), with the screen-reader tree turned on.
struct Frames {
ctx: egui::Context,
time: f64,
}
impl Frames {
fn new() -> Self {
let ctx = egui::Context::default();
ctx.enable_accesskit();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
Self { ctx, time: 0.0 }
}
fn run(&mut self, app: &mut VectorcraftApp, events: Vec<egui::Event>) -> egui::FullOutput {
self.time += 0.1;
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 900.0));
let input = egui::RawInput { events, time: Some(self.time), screen_rect: Some(screen), ..Default::default() };
let mut out = self.ctx.run_ui(input, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
out
}
/// The center of the button that screen readers name `label`.
fn button(&mut self, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 {
self.run(app, vec![]);
let out = self.run(app, vec![]);
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button);
let b = node.unwrap_or_else(|| panic!("no button named {label:?}")).1.bounds().expect("bounds");
egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)
}
fn press(&mut self, app: &mut VectorcraftApp, at: egui::Pos2, pressed: bool) {
let button = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
self.run(app, vec![egui::Event::PointerMoved(at), button]);
}
fn drag(&mut self, app: &mut VectorcraftApp, from: egui::Pos2, by: egui::Vec2) {
self.press(app, from, true);
for k in 1..=4 {
self.run(app, vec![egui::Event::PointerMoved(from + by * k as f32 / 4.0)]);
}
self.press(app, from + by, false);
}
fn key(&mut self, app: &mut VectorcraftApp, key: egui::Key, modifiers: egui::Modifiers) {
let event = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
self.run(app, vec![egui::Event::ModifiersChanged(modifiers), event]);
self.run(app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
}
}
/// The corners in the open dialog, and those of the first object's first effect.
fn free_distort_corners(app: &VectorcraftApp) -> (serde_json::Value, serde_json::Value) {
let s = app.session.active().unwrap();
let node = s.doc.node(s.selection.objects[0]).unwrap();
(app.ui.dialog.as_ref().unwrap().fields["corners"].clone(), node.appearance.effects[0].params["corners"].clone())
}
/// Free Distort's dialog moves the corner whose handle is dragged and leaves the other three; the
/// preview and OK apply them, and the arrow keys move the handle that a click focused.
#[test]
fn free_distort_drags_one_corner_and_previews_it() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
app.run("effect.dialog", json!({"effect": "distort.freeDistort"})).unwrap();
let mut f = Frames::new();
// The box keeps the object's 2:1 proportions: 180 by 90 screen points.
let top_right = f.button(&mut app, "Top Right");
f.drag(&mut app, top_right, egui::vec2(27.0, 9.0));
let want = json!([[0.0, 0.0], [1.15, 0.1], [1.0, 1.0], [0.0, 1.0]]);
assert_eq!(free_distort_corners(&app), (want.clone(), want.clone()), "the dragged corner alone moves, and the preview follows");
let bottom_left = f.button(&mut app, "Bottom Left");
f.press(&mut app, bottom_left, true);
f.press(&mut app, bottom_left, false);
f.key(&mut app, egui::Key::ArrowLeft, egui::Modifiers::NONE);
f.key(&mut app, egui::Key::ArrowDown, egui::Modifiers::SHIFT);
let want = json!([[0.0, 0.0], [1.15, 0.1], [1.0, 1.0], [-0.01, 1.1]]);
assert_eq!(free_distort_corners(&app), (want.clone(), want.clone()));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
let s = app.session.active().unwrap();
assert_eq!(s.doc.node(s.selection.objects[0]).unwrap().appearance.effects[0].params["corners"], want);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before, "one undo step");
}
/// An arrow key moves a corner that lies outside the dialog's area by one step, and Reset puts the
/// corners of an applied Free Distort back on the box; OK keeps that.
#[test]
fn free_distort_reset_puts_the_corners_back() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("select.all", json!({})).unwrap();
let moved = json!([[0.2, 0.1], [0.9, 0.0], [1.0, 3.0], [0.0, 1.0]]);
app.run("effect.apply", json!({"effect": "distort.freeDistort", "params": {"corners": moved}})).unwrap();
app.run("effect.dialog", json!({"effect": "distort.freeDistort", "index": 0})).unwrap();
let mut f = Frames::new();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], moved);
let bottom_right = f.button(&mut app, "Bottom Right");
f.press(&mut app, bottom_right, true);
f.press(&mut app, bottom_right, false);
f.key(&mut app, egui::Key::ArrowUp, egui::Modifiers::NONE);
let stepped = json!([[0.2, 0.1], [0.9, 0.0], [1.0, 2.99], [0.0, 1.0]]);
assert_eq!(free_distort_corners(&app), (stepped.clone(), stepped));
let reset = f.button(&mut app, "Reset");
f.press(&mut app, reset, true);
f.press(&mut app, reset, false);
f.run(&mut app, vec![]);
let rest = json!(super::free_distort::IDENTITY);
assert_eq!(free_distort_corners(&app), (rest.clone(), rest.clone()));
confirm(&mut app).unwrap();
let s = app.session.active().unwrap();
let effects = &s.doc.node(s.selection.objects[0]).unwrap().appearance.effects;
assert_eq!((effects.len(), effects[0].params["corners"].clone()), (1, rest));
}
#[test]
fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
use vectorcraft_doc::NodeId;
+5
View File
@@ -4356,6 +4356,11 @@ theme Appearance Erscheinungsbild
@msg give the path `id` geben Sie die `id` des Pfads an
@msg invalid stroke widths ungültige Konturbreiten
@msg object has no weighted stroke das Objekt hat keine Kontur mit einer gültigen positiven Stärke
# Free Distort: the names of the handles on the four corners of its box.
Top Left Oben links
Top Right Oben rechts
Bottom Right Unten rechts
Bottom Left Unten links
# Corner Radius: a field per corner of a live rectangle.
Single Corner Radius Einheitlicher Eckenradius
Top Left: Oben links:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+8
View File
@@ -736,6 +736,7 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
}
}
let mut change: Option<(String, Value)> = None;
let mut edit = false;
let built_in = built_in_effect(&e.id);
let (unit, relative) = (app.session.general_unit(), params.get("relative").and_then(Value::as_bool).unwrap_or(false));
egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin { left: (EYE_W + 12.0) as i8, right: 6, top: 4, bottom: 4 }).show(ui, |ui| {
@@ -788,6 +789,10 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
change = Some((key.clone(), json!(buf)));
}
}
// Free Distort's corners are dragged in its dialog.
_ if e.id == crate::dialogs::free_distort::FREE_DISTORT => {
edit |= widgets::flat_button(ui, tl!("Edit…"), 48.0).clicked();
}
other => {
widgets::dim_label(ui, &other.to_string());
}
@@ -798,6 +803,9 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
if let Some((key, v)) = change {
app.run("effect.setParams", json!({"item": item, "index": k, "params": {key: v}})).ok();
}
if edit {
app.run("effect.dialog", json!({"effect": e.id, "index": k, "item": item})).ok();
}
}
/// "offsetX" → "Offset X".
@@ -72,6 +72,21 @@ fn an_effect_name_opens_its_dialog_prefilled_and_ok_edits_it_in_place() {
assert_eq!(effects(&app, None)[0].params["size"], json!(9));
}
#[test]
fn free_distorts_inline_editor_opens_its_dialog_for_the_corners() {
let mut app = app_with_rect();
let corners = json!([[0.2, 0.0], [1.0, 0.1], [1.0, 1.0], [0.0, 1.0]]);
run(&mut app, "effect.apply", json!({"effect": "distort.freeDistort", "params": {"corners": corners}}));
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
set_pstate(&ctx, "ap-fx-open-None-0", true);
let texts = frame_events(&ctx, &mut app, vec![], appearance::show);
click(&ctx, &mut app, text_rect(&texts, "Edit…").center(), appearance::show);
let d = app.ui.dialog.clone().expect("Free Distort's dialog");
assert_eq!((d.kind.as_str(), d.str("__effect"), d.fields["__index"].clone()), ("effect", "distort.freeDistort".into(), json!(0)));
assert_eq!(d.fields["corners"], corners);
}
#[test]
fn choosing_an_applied_effect_asks_to_edit_it_or_add_another() {
let mut app = app_with_rect();
+1 -1
View File
@@ -22,7 +22,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
|---|---|---:|---|
| Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — |
| Crop Marks | Crop Marks | 1/1 | — |
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) |
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) |
| Path | Offset Path, Outline Object, Outline Stroke | 2/3 | Outline Object |
| Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap |
| Rasterize | Rasterize… (as a live effect) | 0/1 | the live effect (Object › Rasterize exists) |
+1
View File
@@ -502,6 +502,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Public references: [Simplify benefits](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-path-benefits.html) and [advanced options](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/simplify-paths-advanced-options-overview.html).
Nearly unchanged appearance is expected when reducing points. Percentage/auto-simplify controls, the corner-angle UI and Show Original are not implemented; the checklist is partial, without raising scores.
Simplify and Add Anchor Points record no undo step when they change no path, and a path they leave as it is keeps its live shape.
- Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291).
- **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). 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. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boLine truncated
- **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged.
An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`).