mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
feat(tools): Double-click a frame handle to fit the frame to its content
In InDesign double-clicking a selection handle fits the frame to its
content: a corner fits both sides, a top or bottom handle the height, a
side handle the width. LYH Studio's tutorial uses it at 9:30 to fit the
INDESIGN text frame before moving it to the corner. In DesignCraft the
double-click switched to the Type tool.
- Selection tool: a double-click on a handle runs object.fit
{mode: fitFrameToContent, axis: both|height|width}.
- object.fit fitFrameToContent fits text frames to their composed text:
the bottom to the last line's descent, the right side to the longest
line (one column), plus the insets, keeping the top-left. Overset text
grows the frame first. Before, it fitted graphic frames only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
paaspaas00
co-authored by
Claude Opus 5.5
parent
d020b4e85d
commit
4e250565ac
@@ -132,6 +132,7 @@ Newest first.
|
||||
- **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.
|
||||
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
|
||||
- **2026-10-12** · Double-clicking a frame handle fits the frame to its content as in InDesign (a corner fits both sides, an edge only its own). Object › Fitting › Fit Frame to Content now fits text frames to their text too, growing them for overset text. No readiness percentage change.
|
||||
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
|
||||
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
|
||||
- **2026-10-11** · `object.fill` and `object.stroke` refuse a swatch name the document doesn't have (it painted nothing) and suggest the name meant (`Paper` → `[Paper]`). No readiness percentage change.
|
||||
|
||||
@@ -568,7 +568,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fitting",
|
||||
["Object", "Fitting"],
|
||||
None,
|
||||
"{mode: fillProportionally|fitProportionally|fitContentToFrame|centerContent|fitFrameToContent, ids?}",
|
||||
"{mode: fillProportionally|fitProportionally|fitContentToFrame|centerContent|fitFrameToContent, axis?: both|height|width (text frames: which sides Fit Frame to Content moves; default both), ids?}",
|
||||
has_selection,
|
||||
fit
|
||||
),
|
||||
@@ -1763,7 +1763,28 @@ fn fitting_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let mode = str_param(p, "mode").unwrap_or("fitProportionally").to_string();
|
||||
let (height, width) = match str_param(p, "axis").unwrap_or("both") {
|
||||
"height" => (true, false),
|
||||
"width" => (false, true),
|
||||
"both" => (true, true),
|
||||
other => return Err(bad("object.fit", format!("unknown axis `{other}`"))),
|
||||
};
|
||||
// Text frames: Fit Frame to Content fits the frame to its composed text.
|
||||
let mut text_fits = Vec::new();
|
||||
if mode == "fitFrameToContent" {
|
||||
let d = s.doc()?.doc.clone();
|
||||
for id in &ids {
|
||||
if let Some(r) = fitted_text_frame(&d, &s.cache, *id, height, width) {
|
||||
text_fits.push((*id, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for (id, r) in &text_fits {
|
||||
if let Some(it) = d.item_mut(*id) {
|
||||
it.path = shapes::rectangle(*r);
|
||||
}
|
||||
}
|
||||
for id in &ids {
|
||||
let Some(it) = d.item_mut(*id) else { continue };
|
||||
let r = it.inner_bounds();
|
||||
@@ -1785,6 +1806,47 @@ fn fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The inner rectangle that fits text frame `id` to its text (Fit Frame to Content): the bottom at
|
||||
/// the last line's descent (`height`), the right side at the longest line's end (`width`, one
|
||||
/// column only), plus the insets; the top-left stays. Overset text grows the frame first. None for
|
||||
/// frames that aren't horizontal text frames, have no text, or are threaded on to another frame.
|
||||
fn fitted_text_frame(d: &Document, cache: &designcraft_compose::Cache, id: ItemId, height: bool, width: bool) -> Option<Rect> {
|
||||
let it = d.item(id)?;
|
||||
let tf = it.text_frame()?;
|
||||
if d.frame_vertical(it) || tf.options.path.is_some() {
|
||||
return None;
|
||||
}
|
||||
let story = d.story(tf.story)?;
|
||||
// The last frame of its thread only: an earlier frame's text runs on.
|
||||
if story.frames.last() != Some(&id) {
|
||||
return None;
|
||||
}
|
||||
let r = it.inner_bounds();
|
||||
let inset = tf.options.inset;
|
||||
// Room for everything: a tall frame, composed on a copy.
|
||||
let mut tall = d.clone();
|
||||
if height {
|
||||
tall.item_mut(id)?.path = shapes::rectangle(Rect::new(r.x0, r.y0, r.x1, r.y0 + r.height().max(1.0) + 20_000.0));
|
||||
}
|
||||
let cs = cache.get(&tall, tf.story, None);
|
||||
let ft = cs.frame(id)?;
|
||||
let last = ft.lines.iter().map(|l| l.baseline + l.descent).fold(f64::NEG_INFINITY, f64::max);
|
||||
if !last.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut out = r;
|
||||
if height {
|
||||
out.y1 = (last + inset[2]).max(r.y0 + 1.0);
|
||||
}
|
||||
if width && tf.options.columns <= 1 {
|
||||
let right = ft.lines.iter().map(|l| l.end_x).fold(f64::NEG_INFINITY, f64::max);
|
||||
if right.is_finite() {
|
||||
out.x1 = (right + inset[3]).max(r.x0 + 1.0);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub(crate) trait RemoveKeep {
|
||||
fn remove_item_keep_story(&mut self, id: ItemId) -> Result<Item>;
|
||||
}
|
||||
@@ -2813,3 +2875,52 @@ mod swatch_name_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fit_text_frame_tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(s: &mut Session, rect: [f64; 4], text: &str) -> ItemId {
|
||||
let id = ItemId(s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("selection.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: ItemId) -> Rect {
|
||||
s.doc().unwrap().doc.item(id).unwrap().bounds()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_frame_to_content_fits_a_text_frame_to_its_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = frame(&mut s, [100.0, 100.0, 500.0, 300.0], "INDESIGN");
|
||||
// The height only (a top or bottom handle): the width stays.
|
||||
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
|
||||
let h = bounds(&s, id);
|
||||
assert!(h.x1 == 500.0 && h.y0 == 100.0 && h.height() > 30.0 && h.height() < 45.0, "{h:?}");
|
||||
// Both (a corner handle, or the menu): around the line.
|
||||
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "ids": [id.0]})).unwrap();
|
||||
let b = bounds(&s, id);
|
||||
assert!(b.x0 == 100.0 && b.width() > 100.0 && b.width() < 250.0, "{b:?}");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let sid = d.item(id).unwrap().text_frame().unwrap().story;
|
||||
assert!(!s.cache.get(&d, sid, None).is_overset(), "still fits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_frame_to_content_grows_a_frame_with_overset_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = frame(&mut s, [100.0, 100.0, 300.0, 140.0], "one two three four five six seven eight nine ten eleven twelve");
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let sid = d.item(id).unwrap().text_frame().unwrap().story;
|
||||
assert!(s.cache.get(&d, sid, None).is_overset());
|
||||
s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "height", "ids": [id.0]})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(!s.cache.get(&d, sid, None).is_overset(), "{:?}", bounds(&s, id));
|
||||
assert!(bounds(&s, id).height() > 100.0);
|
||||
assert!(s.execute("object.fit", &json!({"mode": "fitFrameToContent", "axis": "diagonal", "ids": [id.0]})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Click selects the frontmost item (Shift toggles), drag moves (Alt duplicates, Shift constrains),
|
||||
//! the 8 bounding-box handles resize (Shift keeps proportions, Alt from centre, Cmd scales content),
|
||||
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool.
|
||||
//! empty-canvas drags make a marquee, double-clicking a text frame switches to the Type tool and
|
||||
//! double-clicking a handle fits the frame to its content (corner: both sides, edge: that one).
|
||||
|
||||
use designcraft_doc::{ItemId, SpreadRef};
|
||||
use designcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
@@ -707,6 +708,18 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
// A selection handle: Fit Frame to Content, along that handle's side (InDesign).
|
||||
if !self.direct
|
||||
&& let Some(h) = handle_at(cx, p)
|
||||
{
|
||||
let axis = match h {
|
||||
1 | 5 => "height",
|
||||
3 | 7 => "width",
|
||||
_ => "both",
|
||||
};
|
||||
self.drag = Drag::None;
|
||||
return vec![Action::Exec("object.fit".into(), json!({"mode": "fitFrameToContent", "axis": axis}))];
|
||||
}
|
||||
if let Some((_, id)) = cx.hit(p)
|
||||
&& cx.doc.item(id).is_some_and(|i| i.is_text_frame())
|
||||
{
|
||||
|
||||
@@ -705,3 +705,18 @@ fn pen_click_snaps_to_a_guide() {
|
||||
let x = p["anchors"][0]["p"][0].as_f64().unwrap();
|
||||
assert!((x - 100.0).abs() < 1e-6, "anchor x {x}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_handle_fits_the_frame_to_its_content_along_that_side() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 300.0, 200.0));
|
||||
let s = Selection { items: vec![id], ..Default::default() };
|
||||
let (c, l) = (Cache::new(), CanvasLayout::new(&d, false));
|
||||
let off = l.xf(SpreadRef::Doc(0)).translation();
|
||||
let cx = ctx(&d, &s, &c, &l);
|
||||
let mut t = create("selection");
|
||||
for ((x, y), axis) in [((300.0, 200.0), "both"), ((200.0, 200.0), "height"), ((300.0, 150.0), "width")] {
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x + off.x, y + off.y));
|
||||
assert_eq!(a, vec![Action::Exec("object.fit".into(), serde_json::json!({"mode": "fitFrameToContent", "axis": axis}))], "{axis}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user