mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user