Merge table-border-drag: Tables: drag row and column borders with the Type tool

Conflict: crates/compose/src/lib.rs: one re-export line with next's hit_cell_with and the branch's BorderHit/hit_border
Conflict: crates/tools/src/text.rs: next's IME/preedit, guides and unit_selected kept alongside the border drag; border press checked after the Down resets; drag keeps next's `cell_drag || unit_selected` guard after the border branch; Esc cancels a border drag before next's preedit guard; BorderHit via next's `compose` alias
Conflict: crates/tools/src/tests.rs: both sides' appended tests kept
Conflict: docs/target-app-parity.md: next's IDML line, branch's Tables line
Conflict: ROADMAP.md: branch entry on top of next's entries
This commit is contained in:
aiongg
2026-10-12 14:56:19 +00:00
7 changed files with 427 additions and 7 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-12** · With the Type tool, dragging a table's column or row border resizes it (the double-arrow cursor shows over a border): later columns and rows move along, Shift keeps the table size, and Shift on the right or bottom edge scales every column or row. A dragged row becomes At Least; one undo step per drag, Esc cancels; works in rotated frames and turned spreads. Scripts use `table.resizeBorder`. No readiness percentage change.
- **2026-10-12** · The Tools panel's Fill/Stroke keys work: D (default fill and stroke), X (fill or stroke in front), Shift+X (swap per object, tints included) and / (Apply None), as `color.*` commands; swap and default are one undo step (`object.swapFillStroke`, `object.defaultFillStroke`); the Color panel follows the proxy's fill/stroke choice. No readiness percentage change.
- **2026-10-12** · With a placed graphic selected (Direct Selection), the Transform values act on the graphic inside its frame, as in InDesign: a tilted frame's image can be straightened to 0°. No readiness percentage change.
- **2026-10-11** · Font menu laid out like InDesign's: filters (classification, favourites,
+1 -1
View File
@@ -49,7 +49,7 @@ use crate::notes::Notes;
pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret};
use crate::parabox::ParaBox;
use crate::shape::{Glyph, StyleTable, SubstCtx};
pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell, hit_cell_with};
pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with};
/// Character appearance shared by many glyphs (indexed from [`PlacedGlyph::style`]).
#[derive(Clone, Debug, PartialEq)]
+50
View File
@@ -719,6 +719,56 @@ pub fn hit_cell_with(
None
}
/// A row or column border under the pointer, found by [`hit_border`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BorderHit {
pub table: u64,
/// `true`: a column border (dragged horizontally); `false`: a row border.
pub column: bool,
/// The row above / logical column before the border. The last index is the table's
/// bottom or end edge.
pub index: usize,
/// The column border is the physical left edge of its column (a right-to-left table), so
/// dragging left widens it.
pub mirrored: bool,
}
/// The row or column border within `tol` of `p` (frame inner space) in frame `fi`, nearest
/// first. Only borders that resize something count: inner borders and the bottom and end
/// edges. `tables` holds the story's tables (for cell spans).
pub fn hit_border(cs: &ComposedStory, fi: usize, tables: &std::collections::BTreeMap<u64, Arc<Table>>, p: Point, tol: f64) -> Option<BorderHit> {
if !(tol.is_finite() && p.x.is_finite() && p.y.is_finite()) {
return None;
}
let ft = cs.frames.get(fi)?;
let mut best: Option<(f64, BorderHit)> = None;
let mut offer = |d: f64, hit: BorderHit| {
if d <= tol && best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, hit));
}
};
for t in &ft.tables {
if !t.rect.inflate(tol, tol).contains(p) {
continue;
}
let Some(table) = tables.get(&t.table) else { continue };
let rtl = table.options.direction == designcraft_doc::TextDirection::RightToLeft;
for c in &t.cells {
let Some(cell) = table.cell(c.row, c.col) else { continue };
let last_col = c.col.saturating_add((cell.col_span.max(1) - 1) as usize);
let last_row = c.row.saturating_add((cell.row_span.max(1) - 1) as usize);
if (c.rect.y0..=c.rect.y1).contains(&p.y) {
let x = if rtl { c.rect.x0 } else { c.rect.x1 };
offer((p.x - x).abs(), BorderHit { table: t.table, column: true, index: last_col, mirrored: rtl });
}
if (c.rect.x0..=c.rect.x1).contains(&p.x) {
offer((p.y - c.rect.y1).abs(), BorderHit { table: t.table, column: false, index: last_row, mirrored: false });
}
}
}
best.map(|(_, h)| h)
}
/// Frame index and placed cell for a cell address (the first, non-repeated occurrence).
pub fn find_cell(cs: &ComposedStory, table: u64, row: usize, col: usize) -> Option<(usize, &TableFrag, &PlacedCell)> {
let mut found = None;
+211
View File
@@ -211,6 +211,15 @@ fn table_specs() -> Vec<CommandSpec> {
),
cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height),
cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width),
cmd!(
"table.resizeBorder",
"Resize Row or Column",
[],
None,
"{story?, table?, frame?, axis: column|row, index, delta? | size?, keepTableSize?: bool} — moves the border after column/row `index` by `delta` (or makes that column/row `size` wide/high); later columns/rows move along. The last index is the table's end/bottom edge. keepTableSize: on an inner border the next column/row gives or takes the change; on the edge all columns/rows scale proportionally. A dragged row becomes At Least. Refuses a locked `frame`",
in_table_or_ids,
resize_border
),
cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols),
cmd!(
"table.distributeRows",
@@ -1029,6 +1038,114 @@ fn set_col_width(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Smallest column width and row height a border drag leaves.
const MIN_TRACK: f64 = 3.0;
const MAX_TRACK: f64 = 10000.0;
/// Composed height of `row` (laid out from cells that span only that row), else its model height.
fn composed_row_height(cs: &compose::ComposedStory, t: &Table, row: usize) -> Option<f64> {
let laid_out = cs
.frames
.iter()
.flat_map(|f| &f.tables)
.filter(|f| f.table == t.id)
.flat_map(|f| &f.cells)
.filter(|c| c.row == row && t.cell(c.row, c.col).is_some_and(|x| x.row_span <= 1))
.map(|c| c.rect.height())
.filter(|h| h.is_finite() && *h > 0.0)
.reduce(f64::max);
laid_out.or_else(|| t.rows.get(row).map(|r| r.height))
}
/// Move the border after `index` among `sizes` by `delta`. Returns the new sizes.
fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option<Vec<f64>> {
let mut out = sizes.to_vec();
let cur = *sizes.get(index)?;
let last = index + 1 == sizes.len();
if keep && last {
// Scale every track so the table edge moves by `delta`.
let total: f64 = sizes.iter().sum();
let smallest = sizes.iter().copied().fold(f64::INFINITY, f64::min);
let largest = sizes.iter().copied().fold(0.0, f64::max);
if !(total > 0.0 && smallest > 0.0 && largest > 0.0) {
return None;
}
let lo = (MIN_TRACK / smallest).min(1.0);
let hi = (MAX_TRACK / largest).max(1.0);
let f = ((total + delta) / total).clamp(lo, hi);
out.iter_mut().for_each(|v| *v *= f);
} else if keep {
let next = *sizes.get(index + 1)?;
let pair = cur + next;
let a = (cur + delta).clamp(MIN_TRACK, (pair - MIN_TRACK).max(MIN_TRACK));
let [this, after] = out.get_mut(index..index + 2)? else { return None };
*this = a;
*after = (pair - a).max(MIN_TRACK);
} else {
*out.get_mut(index)? = (cur + delta).clamp(MIN_TRACK, MAX_TRACK);
}
out.iter().all(|v| v.is_finite()).then_some(out)
}
fn resize_border(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.resizeBorder";
let column = match str_param(p, "axis") {
Some("column") => true,
Some("row") => false,
_ => return Err(bad(ID, "axis must be `column` or `row`")),
};
let index = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(ID, "`index` required"))?;
let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false);
let g = target(s, p, ID)?;
let st = s.doc()?;
if let Some(fid) = super::id_param(p, "frame") {
let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?;
if it.text_frame().map(|f| f.story) != Some(g.story) {
return Err(bad(ID, "the table is not in that frame's story"));
}
if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) {
return Err(bad(ID, "the frame or its layer is locked"));
}
}
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
let sizes: Vec<f64> = if column {
t.columns.iter().map(|c| c.width).collect()
} else {
let cs = s.cache.get(&st.doc, g.story, None);
(0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect()
};
let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?;
let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) {
(Some(d), _) => d,
(None, Some(sz)) => sz - cur,
(None, None) => return Err(bad(ID, "`delta` or `size` required")),
};
if !delta.is_finite() {
return Err(bad(ID, "`delta`/`size` must be a finite number"));
}
let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?;
let size = new.get(index).copied().unwrap_or(cur);
let scaled = keep && index + 1 == sizes.len();
edit_table(s, &g, ID, |t| {
if column {
for (c, w) in t.columns.iter_mut().zip(&new) {
c.width = *w;
}
} else {
for (i, (r, h)) in t.rows.iter_mut().zip(&new).enumerate() {
// Only the rows the drag changed take the new height.
if scaled || i == index || (keep && i == index + 1) {
r.height = *h;
}
if i == index && !scaled {
r.mode = RowHeightMode::AtLeast;
}
}
}
Ok(json!({"axis": if column { "column" } else { "row" }, "index": index, "size": size}))
})
}
fn distribute_rows(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "table.distributeRows";
if !p.is_object() {
@@ -1967,3 +2084,97 @@ mod arabic_tests {
}
}
}
#[cfg(test)]
mod resize_border_tests {
use serde_json::json;
use crate::Session;
/// A 3×3 table of 80 pt columns; returns the session, frame id, story and table ids.
fn session() -> (Session, u64, u64, u64) {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 600], "content": "text", "text": ""})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
let t = s.execute("table.insert", &json!({"rows": 3, "cols": 3, "width": 240})).unwrap();
(s, r["id"].as_u64().unwrap(), t["story"].as_u64().unwrap(), t["table"].as_u64().unwrap())
}
fn table(s: &Session) -> designcraft_doc::Table {
(**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone()
}
fn widths(s: &Session) -> Vec<f64> {
table(s).columns.iter().map(|c| c.width).collect()
}
/// Composed cell rects of column 0, top to bottom.
fn rows(s: &Session) -> Vec<designcraft_geom::Rect> {
let st = s.doc().unwrap();
let sid = st.doc.stories.values().find(|x| !x.tables.is_empty()).unwrap().id;
let cs = s.cache.get(&st.doc, sid, None);
cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect).collect()
}
fn resize(s: &mut Session, ids: (u64, u64, u64), mut p: serde_json::Value) {
p["frame"] = json!(ids.0);
p["story"] = json!(ids.1);
p["table"] = json!(ids.2);
s.execute("table.resizeBorder", &p).unwrap();
}
#[test]
fn column_border_drags_widen_the_table_or_keep_its_width() {
let (mut s, f, st, t) = session();
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": 20}));
assert_eq!(widths(&s), [100.0, 80.0, 80.0]);
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 30, "keepTableSize": true}));
assert_eq!(widths(&s), [100.0, 110.0, 50.0]);
// Clamped at the minimum; the neighbour takes only what is left.
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 500, "keepTableSize": true}));
assert_eq!(widths(&s), [100.0, 157.0, 3.0]);
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": -1000}));
assert_eq!(widths(&s), [3.0, 157.0, 3.0]);
// Shift on the end edge scales every column.
resize(&mut s, (f, st, t), json!({"axis": "column", "index": 2, "size": 166, "keepTableSize": true}));
let w = widths(&s);
assert!((w.iter().sum::<f64>() - 326.0).abs() < 1e-9, "{w:?}");
assert!((w[1] / w[0] - 157.0 / 3.0).abs() < 1e-9, "{w:?}");
assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 3, "delta": 1})).is_err());
assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 0})).is_err());
}
#[test]
fn row_drag_sets_at_least_and_moves_later_rows() {
let (mut s, f, st, t) = session();
s.execute("table.setRowHeight", &json!({"table": t, "row": 0, "height": 30, "mode": "exactly"})).unwrap();
let before = rows(&s);
resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": 12}));
let row = &table(&s).rows[0];
assert_eq!((row.height, row.mode), (42.0, designcraft_doc::RowHeightMode::AtLeast));
let after = rows(&s);
assert!((after[0].height() - 42.0).abs() < 1e-9);
assert!((after[1].y0 - before[1].y0 - 12.0).abs() < 1e-9 && (after[1].height() - before[1].height()).abs() < 1e-9);
// Below the minimum: clamped, never negative.
resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": -1000}));
assert_eq!(table(&s).rows[0].height, 3.0);
}
#[test]
fn a_drag_is_one_undo_step_and_locked_frames_refuse() {
let (mut s, f, st, t) = session();
let before = table(&s);
s.begin_interaction("Resize Column").unwrap();
for d in [5.0, 15.0, 25.0] {
let p = json!({"frame": f, "story": st, "table": t, "axis": "column", "index": 0, "delta": d});
s.preview("table.resizeBorder", p).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(widths(&s), [105.0, 80.0, 80.0]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table(&s), before);
s.execute("object.setFlags", &json!({"ids": [f], "locked": true})).unwrap();
assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err());
}
}
+54
View File
@@ -1331,3 +1331,57 @@ fn anchor_tools_show_pen_state_cursors() {
assert_eq!(cursor("deleteAnchor"), Cursor::PenDelete);
assert_eq!(cursor("convertDirection"), Cursor::PenConvert);
}
/// A text frame holding a 3×3 table of 80 pt columns, optionally rotated about its centre.
fn table_frame(degrees: f64) -> (Document, ItemId) {
let mut d = Document::new(&NewDocument::default());
let layer = d.default_layer();
let (id, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 400.0, 400.0), layer, "", ParaFormat::default()).unwrap();
let tid = d.alloc();
d.story_mut(sid).unwrap().insert_table(0, designcraft_doc::Table::new(tid, 3, 3, 0, 0, 240.0));
d.item_mut(id).unwrap().xf = designcraft_geom::Affine::rotate_about(degrees.to_radians(), Point::new(250.0, 250.0));
(d, id)
}
#[test]
fn type_tool_drags_a_table_column_border() {
for degrees in [0.0, 90.0] {
let (d, id) = table_frame(degrees);
let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
let cx = ctx(&d, &s, &c, &l);
let it = d.item(id).unwrap();
let sid = it.text_frame().unwrap().story;
let cs = c.get(&d, sid, None);
let cell = cs.frames[0].tables[0].cell(1, 0).unwrap();
let xf = cx.item_canvas_xf(id).unwrap() * d.text_xf(it);
let on_border = xf * Point::new(cell.rect.x1 + 1.0, (cell.rect.y0 + cell.rect.y1) / 2.0);
let dragged = xf * Point::new(cell.rect.x1 + 21.0, (cell.rect.y0 + cell.rect.y1) / 2.0);
let mut t = create("type");
let expected = if degrees == 0.0 { Cursor::ResizeH } else { Cursor::ResizeV };
assert_eq!(t.cursor(&cx, on_border, Mods::default()), expected);
// A click with a 1 px wobble on the border changes nothing.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)).is_empty());
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, on_border.x + 1.0, on_border.y)).is_empty());
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, on_border.x + 1.0, on_border.y)).is_empty());
// A press on the border starts a resize, not a text selection.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, dragged.x, dragged.y));
assert_eq!(a[0], Action::Begin("Resize Column".into()));
let Action::Preview(cmd, p) = &a[1] else { panic!("{a:?}") };
assert_eq!(
(cmd.as_str(), &p["axis"], &p["index"], &p["frame"]),
("table.resizeBorder", &serde_json::json!("column"), &serde_json::json!(0), &serde_json::json!(id.0))
);
assert!((p["delta"].as_f64().unwrap() - 20.0).abs() < 1e-6, "{p}");
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, dragged.x, dragged.y)), vec![Action::Commit]);
// Esc cancels a drag in progress.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, on_border.x, on_border.y));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, dragged.x, dragged.y));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, dragged.x, dragged.y)).is_empty());
// Inside the cell the Type tool places the caret as usual.
let inside = xf * Point::new(cell.rect.x0 + 20.0, (cell.rect.y0 + cell.rect.y1) / 2.0);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, inside.x, inside.y));
assert!(matches!(&a[..], [Action::Exec(cmd, _)] if cmd == "text.placeCaret"), "{a:?}");
}
}
+109 -5
View File
@@ -1,6 +1,7 @@
//! Type tool (T): click in a frame to place the caret, drag in a frame to select text, drag on
//! empty canvas to draw a new text frame. Keys move the caret / delete; typing goes through
//! `text.insert` (sent by the UI as text input).
//! `text.insert` (sent by the UI as text input). Dragging a table's row or column border resizes
//! it (`table.resizeBorder`).
//!
//! IME: the marked text of a composition is a preview of `text.insert` at the caret (so it lays
//! out in place like typed text), underlined, until the IME commits it (one `text.insert`, one
@@ -38,6 +39,86 @@ pub struct TypeTool {
/// The press selected a word, line, paragraph or story (several clicks): drags keep it.
unit_selected: bool,
preedit: Option<Preedit>,
/// A press on a table row or column border: a drag resizes it.
border: Option<BorderDrag>,
}
/// A table border under the pointer, and the frame's canvas → inner-space mapping.
#[derive(Clone, Copy)]
struct BorderDrag {
frame: u64,
story: u64,
hit: compose::BorderHit,
/// Canvas → frame inner space (through rotations, scaling and turned spreads).
inv: Affine,
/// Press point in frame inner space.
start: Point,
begun: bool,
/// The border runs vertically on the canvas (column borders on an unrotated frame).
horizontal_drag: bool,
}
/// The row or column border of a table near canvas point `pos`, in an unlocked text frame.
fn border_at(cx: &ToolContext, pos: Point) -> Option<BorderDrag> {
let (_, id) = cx.hit(pos)?;
let it = cx.doc.item(id)?;
if it.locked {
return None;
}
let sid = it.text_frame()?.story;
let story = cx.doc.story(sid)?;
if story.tables.is_empty() {
return None;
}
let xf = cx.item_canvas_xf(id)? * cx.doc.text_xf(it);
let det = xf.determinant().abs();
if !(det.is_finite() && det > 1e-12) {
return None;
}
let inv = xf.inverse();
let start = inv * pos;
let cs = cx.cache.get(cx.doc, sid, None);
let fi = cs.frames.iter().position(|f| f.frame == id)?;
let hit = compose::hit_border(&cs, fi, &story.tables, start, cx.tol(4.0) / det.sqrt())?;
let axis = if hit.column { Point::new(1.0, 0.0) } else { Point::new(0.0, 1.0) };
let v = (xf * axis) - (xf * Point::ORIGIN);
Some(BorderDrag { frame: id.0, story: sid.0, hit, inv, start, begun: false, horizontal_drag: v.x.abs() >= v.y.abs() })
}
impl BorderDrag {
fn cursor(&self) -> Cursor {
if self.horizontal_drag { Cursor::ResizeH } else { Cursor::ResizeV }
}
/// Begin (once) and preview the resize for the pointer at canvas `pos`.
fn drag(&mut self, pos: Point, keep: bool) -> Vec<Action> {
let d = (self.inv * pos) - self.start;
let mut delta = if self.hit.column { d.x } else { d.y };
if self.hit.mirrored {
delta = -delta;
}
if !delta.is_finite() {
return vec![];
}
let mut out = vec![];
if !self.begun {
self.begun = true;
out.push(Action::Begin(if self.hit.column { "Resize Column" } else { "Resize Row" }.into()));
}
out.push(Action::Preview(
"table.resizeBorder".into(),
json!({
"frame": self.frame,
"story": self.story,
"table": self.hit.table,
"axis": if self.hit.column { "column" } else { "row" },
"index": self.hit.index,
"delta": delta,
"keepTableSize": keep,
}),
));
out
}
}
/// Are whole rows or whole columns of a table selected?
@@ -139,6 +220,13 @@ impl Tool for TypeTool {
self.selecting = None;
self.cell_drag = None;
self.unit_selected = false;
self.border = None;
if ev.kind == PointerKind::Down
&& let Some(b) = border_at(cx, ev.pos)
{
self.border = Some(b);
return vec![];
}
if let Some((_, id)) = cx.hit(ev.pos)
&& let Some(it) = cx.doc.item(id)
&& !matches!(it.content, designcraft_doc::Content::Graphic(_) | designcraft_doc::Content::Group { .. })
@@ -170,6 +258,13 @@ impl Tool for TypeTool {
}
PointerKind::Drag => {
let Some(a) = self.start else { return vec![] };
if let Some(b) = self.border.as_mut() {
// A click with a little wobble must not resize.
if !b.begun && (ev.pos - a).hypot() < cx.tol(3.0) {
return vec![];
}
return b.drag(ev.pos, ev.mods.shift);
}
if self.cell_drag.is_some() || self.unit_selected {
return vec![];
}
@@ -199,6 +294,9 @@ impl Tool for TypeTool {
PointerKind::Up => {
self.guides.clear();
let start = self.start.take();
if let Some(b) = self.border.take() {
return if b.begun { vec![Action::Commit] } else { vec![] };
}
if let Some((fid, from)) = self.cell_drag.take() {
let sp = cx.layout.spread_at(ev.pos).map(|(_, p)| p).unwrap_or(ev.pos);
let moved = start.is_some_and(|a| (ev.pos - a).hypot() >= cx.tol(3.0));
@@ -228,6 +326,12 @@ impl Tool for TypeTool {
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape
&& let Some(b) = self.border.take()
{
self.start = None;
return if b.begun { vec![Action::Cancel] } else { vec![] };
}
// The IME owns the keys while it composes (the UI holds them back; this is a guard).
if cx.selection.text.is_none() || self.preedit.is_some() {
return vec![];
@@ -277,14 +381,14 @@ impl Tool for TypeTool {
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
match cx.hit(p) {
Some((_, id)) if cx.doc.item(id).is_some_and(|i| i.is_text_frame()) => Cursor::Text,
_ => Cursor::Text,
if let Some(b) = self.border.as_ref().copied().or_else(|| if self.start.is_none() { border_at(cx, p) } else { None }) {
return b.cursor();
}
Cursor::Text
}
fn busy(&self) -> bool {
self.drawing
self.drawing || self.border.is_some()
}
fn wants_text(&self, cx: &ToolContext) -> bool {
+1 -1
View File
@@ -273,7 +273,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands.
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options.