mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge pull request #1845 from dotCooCoo/feat/table-go-to-row
Tables: Table › Go to Row… selects a row by its number
This commit is contained in:
27 files changed
+631
-20
No files matched your search
@@ -462,6 +462,7 @@ Newest first.
|
||||
- **2026-10-24** · A guide pulled out of a ruler keeps the resize cursor across it until it is dropped; the tool's cursor took over as soon as the pointer left the ruler, so the drag looked as if it had stopped. No readiness percentage change.
|
||||
- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.
|
||||
- **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change.
|
||||
- **2026-10-25** · Table › Go to Row… (`table.goToRow {row, section?}`): as in InDesign, a dialog takes a row number and, when the table has header or footer rows, the section is chosen from a menu. The menu lists Body Rows and those of Header Rows and Footer Rows that the table has. DesignCraft counts the row from 1 within its section and selects it, and the view scrolls the row to its middle at the same zoom, on the page in view or another. A row number past the section's last row is refused. No readiness percentage change.
|
||||
- **2026-10-22** · Paragraph Style Options › Paragraph Shading and Paragraph Border are laid out as InDesign's: headed with the page's name; Tint is a spinner with InDesign's presets and has Overprint beside it (shading, border and gap; a new attribute each, kept through IDML; PDF export warns it doesn't overprint yet); Corner Size and Shape is InDesign's two-by-two grid of corner icons, sizes and shape menus showing each shape's glyph, with the link in the middle; offsets and weights have spinners. Found in the Learn Skills Daily course, chapter 41. No readiness percentage change.
|
||||
- **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change.
|
||||
- **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change.
|
||||
|
||||
@@ -146,6 +146,14 @@ impl Cache {
|
||||
c
|
||||
}
|
||||
|
||||
/// Where the host story places anchored text frame `fid`, one of story `sid`'s frames; `None` for
|
||||
/// a frame on a spread.
|
||||
pub fn placement(&self, doc: &Document, sid: StoryId, fid: designcraft_doc::ItemId) -> Option<crate::Placement> {
|
||||
let mut resolved = Resolved::new();
|
||||
let placed = crate::placed::placements(doc, sid, &mut |host| self.resolve(doc, host, true, 1, &mut resolved));
|
||||
placed.into_iter().find_map(|(id, p)| (id == fid).then_some(p))
|
||||
}
|
||||
|
||||
/// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for
|
||||
/// a story `depth` hosts down from the story asked for.
|
||||
fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize, resolved: &mut Resolved) -> Arc<ComposedStory> {
|
||||
|
||||
@@ -53,6 +53,9 @@ pub struct TableFrag {
|
||||
pub column: u32,
|
||||
pub rect: Rect,
|
||||
pub cells: Vec<PlacedCell>,
|
||||
/// The table rows in the fragment, each with its top and bottom; repeated header and footer
|
||||
/// rows are left out.
|
||||
pub rows: Vec<(usize, f64, f64)>,
|
||||
pub strokes: Vec<StrokeSeg>,
|
||||
/// Index of the fragment's (glyph-less) line in the frame's lines.
|
||||
pub line: usize,
|
||||
@@ -89,6 +92,10 @@ impl TableFrag {
|
||||
c.clip = c.clip + v;
|
||||
c.origin += v;
|
||||
}
|
||||
for r in &mut self.rows {
|
||||
r.1 += dy;
|
||||
r.2 += dy;
|
||||
}
|
||||
for s in &mut self.strokes {
|
||||
s.a += v;
|
||||
s.b += v;
|
||||
@@ -500,6 +507,7 @@ fn emit(
|
||||
column: cur.col as u32,
|
||||
rect: Rect::new(x0, top, right, bottom),
|
||||
cells,
|
||||
rows: rows.iter().filter(|r| !r.2).map(|&(r, y, _)| (r, y, y + heights[r])).collect(),
|
||||
strokes,
|
||||
line,
|
||||
first,
|
||||
|
||||
@@ -70,19 +70,23 @@ pub(super) fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.get")?;
|
||||
let st = s.doc()?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?;
|
||||
let owners = t.owners();
|
||||
let cells: Vec<Value> = (0..t.nrows())
|
||||
.flat_map(|r| (0..t.ncols()).map(move |c| (r, c)))
|
||||
.filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c))
|
||||
.filter_map(|(r, c)| {
|
||||
let cell = t.cell(r, c)?;
|
||||
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
|
||||
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
let mut out = json!({
|
||||
"story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)),
|
||||
"rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(),
|
||||
"cells": cells, "options": t.options, "range": g.range, "style": t.style,
|
||||
}))
|
||||
"options": t.options, "range": g.range, "style": t.style,
|
||||
});
|
||||
if p.get("cells").and_then(Value::as_bool) != Some(false) {
|
||||
let owners = t.owners();
|
||||
let cells: Vec<Value> = (0..t.nrows())
|
||||
.flat_map(|r| (0..t.ncols()).map(move |c| (r, c)))
|
||||
.filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c))
|
||||
.filter_map(|(r, c)| {
|
||||
let cell = t.cell(r, c)?;
|
||||
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
|
||||
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style}))
|
||||
})
|
||||
.collect();
|
||||
out["cells"] = json!(cells);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::cell::{get_cell_stroke, set_cell};
|
||||
use super::content::{drop_cells, place_graphic, sort_rows};
|
||||
use super::options::{get, options};
|
||||
use super::selection::{move_rc, select, step_cell, toggle_selection};
|
||||
use super::selection::{go_to_row, move_rc, select, step_cell, toggle_selection};
|
||||
use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height};
|
||||
use super::structure::{
|
||||
can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row,
|
||||
@@ -396,6 +396,15 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| {
|
||||
select(s, &crate::cmd::with_param(p, "what", json!("column")))
|
||||
}),
|
||||
cmd!(
|
||||
noundo "table.goToRow",
|
||||
"Go to Row…",
|
||||
["Table"],
|
||||
None,
|
||||
"{row: number (from 1, counted within the section), section?: header|body|footer (default: body), story?, table?}: selects the row → {story, table, range, page: the document page showing the row (from 1), spread: {kind: doc|parent, index} of the spread showing it, rowBounds: [x0, y0, x1, y1] of the row on that spread; each null when the row is overset, and page on a parent spread}",
|
||||
in_table_or_ids,
|
||||
go_to_row
|
||||
),
|
||||
cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)),
|
||||
cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)),
|
||||
cmd!(
|
||||
@@ -407,6 +416,6 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
in_cell,
|
||||
|s, _| toggle_selection(s)
|
||||
),
|
||||
cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options. Supplied `story` and `table` values must be nonnegative integer IDs. When a table ID occurs in multiple stories, supply `story` with `table`.", in_table_or_ids, get),
|
||||
cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, cells?: bool (false leaves out the cells)} → rows, columns, cells (text, spans, fill), options. Supplied `story` and `table` values must be nonnegative integer IDs. When a table ID occurs in multiple stories, supply `story` with `table`.", in_table_or_ids, get),
|
||||
]
|
||||
}
|
||||
@@ -24,16 +24,113 @@ pub(super) fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => {}
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let frame = st.selection.text.and_then(|t| t.frame);
|
||||
// The caret goes in the cell that covers the range's first position, a merged one included.
|
||||
let (row, col) = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).map_or((r.r0, r.c0), |t| t.owner(r.r0, r.c0));
|
||||
// The caret's frame is kept only within the same story.
|
||||
let frame = st.selection.text.filter(|t| t.story == g.story).and_then(|t| t.frame);
|
||||
st.selection = Selection {
|
||||
cells: Some(TableSel { story: g.story, table: g.table, range: r }),
|
||||
text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }),
|
||||
text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row, col }) }),
|
||||
..Default::default()
|
||||
};
|
||||
st.bump_revision();
|
||||
Ok(json!({"story": g.story.0, "table": g.table, "range": r}))
|
||||
}
|
||||
|
||||
/// Table › Go to Row…: select row `row` (from 1) of the header, body or footer rows.
|
||||
pub(super) fn go_to_row(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "table.goToRow";
|
||||
// Only the table comes from `p`: `row` here counts within a section, not from the table's top.
|
||||
let mut at = serde_json::Map::new();
|
||||
for key in ["story", "table"] {
|
||||
if let Some(v) = p.get(key) {
|
||||
at.insert(key.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let g = target(s, &Value::Object(at), ID)?;
|
||||
let n = p.get("row").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| bad(ID, "row must be a whole number from 1"))?;
|
||||
let (index, owners, nc) = {
|
||||
let st = s.doc()?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
|
||||
let (h, f, nr) = (t.header_rows(), t.footer_rows(), t.nrows());
|
||||
let (section, first, count) = match str_param(p, "section").unwrap_or("body") {
|
||||
"header" => ("header", 0, h),
|
||||
"body" => ("body", h, nr.saturating_sub(h + f)),
|
||||
"footer" => ("footer", nr.saturating_sub(f), f),
|
||||
other => return Err(bad(ID, format!("unknown section `{other}` (header, body or footer)"))),
|
||||
};
|
||||
if count == 0 {
|
||||
return Err(bad(ID, format!("the table has no {section} rows")));
|
||||
}
|
||||
let n = usize::try_from(n).unwrap_or(usize::MAX);
|
||||
if n > count {
|
||||
return Err(bad(ID, format!("row {n} is past the last of the table's {count} {section} rows")));
|
||||
}
|
||||
(first + n - 1, t.owners(), t.ncols())
|
||||
};
|
||||
let r = select(s, &json!({"story": g.story.0, "table": g.table, "rows": [index, index], "what": "row"}))?;
|
||||
// The page of the frame that shows the row: the cells that start in it or reach it from above.
|
||||
let start = index.saturating_mul(nc);
|
||||
let cells: Vec<(usize, usize)> = owners.get(start..start.saturating_add(nc)).map(<[_]>::to_vec).unwrap_or_default();
|
||||
let st = s.doc()?;
|
||||
let cs = s.cache.get(&st.doc, g.story, None);
|
||||
// The part of the table that shows the row, its frame, and the cells that cover the row there.
|
||||
let placed = cs.frames.iter().find_map(|ft| {
|
||||
ft.tables.iter().filter(|t| t.table == g.table).find_map(|t| {
|
||||
let covering: Vec<designcraft_geom::Rect> =
|
||||
t.cells.iter().filter(|c| !c.repeated && cells.contains(&(c.row, c.col))).map(|c| c.rect).collect();
|
||||
(!covering.is_empty()).then_some((ft.frame, t, covering))
|
||||
})
|
||||
});
|
||||
let frame = placed.as_ref().map(|(f, _, _)| *f);
|
||||
let on_spread = frame.and_then(|f| frame_on_spread(&st.doc, &s.cache, g.story, f));
|
||||
// The document page under the row's first cell: a frame can span two pages of a spread.
|
||||
let page = placed.as_ref().zip(on_spread).and_then(|((_, _, covering), (spread, xf))| {
|
||||
let designcraft_doc::SpreadRef::Doc(si) = spread else { return None };
|
||||
let at = xf * covering.first()?.center();
|
||||
Some(st.doc.first_page_of_spread(si) + st.doc.spreads.get(si)?.page_at_x(at.x).unwrap_or(0) + 1)
|
||||
});
|
||||
// The row on its spread: across, the cells that cover it; from top to bottom, the row alone, also
|
||||
// inside cells merged across several rows.
|
||||
let bounds = placed.as_ref().zip(on_spread).and_then(|((_, t, covering), (_, xf))| {
|
||||
let across = covering.iter().copied().reduce(|a, b| a.union(b))?;
|
||||
let &(_, top, bottom) = t.rows.iter().find(|r| r.0 == index)?;
|
||||
Some(xf.transform_rect_bbox(designcraft_geom::Rect::new(across.x0, top, across.x1, bottom)))
|
||||
});
|
||||
let spread = on_spread.map(|(si, _)| si);
|
||||
// The caret is shown in the frame that shows the row, and in no frame when the row is overset.
|
||||
if let Some(t) = s.doc_mut()?.selection.text.as_mut() {
|
||||
t.frame = frame;
|
||||
}
|
||||
Ok(json!({
|
||||
"story": r["story"],
|
||||
"table": r["table"],
|
||||
"range": r["range"],
|
||||
"page": page,
|
||||
"spread": spread,
|
||||
"rowBounds": bounds.map(|b| [b.x0, b.y0, b.x1, b.y1]),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The spread (a document or parent spread) that text frame `frame` of story `sid` is on, and the
|
||||
/// transform from the frame's inner space to that spread's coordinates. An anchored frame is where
|
||||
/// its host story places it.
|
||||
fn frame_on_spread(
|
||||
doc: &designcraft_doc::Document,
|
||||
cache: &designcraft_compose::Cache,
|
||||
sid: StoryId,
|
||||
frame: designcraft_doc::ItemId,
|
||||
) -> Option<(designcraft_doc::SpreadRef, designcraft_geom::Affine)> {
|
||||
let (spread, parent, it) = match doc.find(frame) {
|
||||
Some(loc) => (loc.spread, doc.parent_xf(&loc), doc.item_at(&loc)?),
|
||||
None => {
|
||||
let p = cache.placement(doc, sid, frame)?;
|
||||
(p.spread, p.xf, doc.anchored_item(frame)?)
|
||||
}
|
||||
};
|
||||
Some((spread, parent * doc.text_xf(it)))
|
||||
}
|
||||
|
||||
/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last
|
||||
/// cell adds a row.
|
||||
pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result<Value> {
|
||||
@@ -138,3 +235,222 @@ pub(super) fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
|
||||
Ok(json!({"from": from, "to": to}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod go_to_row_tests {
|
||||
use designcraft_doc::CellRange;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A table of 1 header, 3 body and 1 footer rows, 2 columns wide, with the caret in its first cell.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1, "footerRows": 1})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> CellRange {
|
||||
s.doc().unwrap().selection.cells.unwrap().range
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_selects_a_row_counted_within_its_section() {
|
||||
let mut s = session();
|
||||
let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap();
|
||||
assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 }, "the second body row, under the header row");
|
||||
assert_eq!(r["page"], 1);
|
||||
s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap();
|
||||
assert_eq!(selected(&s), CellRange { r0: 0, c0: 0, r1: 0, c1: 1 });
|
||||
s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).unwrap();
|
||||
assert_eq!(selected(&s), CellRange { r0: 4, c0: 0, r1: 4, c1: 1 });
|
||||
s.execute("table.goToRow", &json!({"row": 3, "section": "body"})).unwrap();
|
||||
assert_eq!(selected(&s), CellRange { r0: 3, c0: 0, r1: 3, c1: 1 });
|
||||
// The caret goes to the row's first cell.
|
||||
assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((3, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_refuses_rows_outside_the_section() {
|
||||
let mut s = session();
|
||||
s.execute("table.goToRow", &json!({"row": 1})).unwrap();
|
||||
let before = selected(&s);
|
||||
for p in [
|
||||
json!({"row": 4}),
|
||||
json!({"row": 0}),
|
||||
json!({"row": -1}),
|
||||
json!({"row": "2"}),
|
||||
json!({}),
|
||||
json!({"row": 2, "section": "footer"}),
|
||||
json!({"row": 1, "section": "side"}),
|
||||
] {
|
||||
assert!(s.execute("table.goToRow", &p).is_err(), "{p}");
|
||||
assert_eq!(selected(&s), before, "{p} leaves the selection");
|
||||
}
|
||||
s.execute("table.options", &json!({"headerRows": 0, "footerRows": 0})).unwrap();
|
||||
assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).is_err());
|
||||
assert!(s.execute("table.goToRow", &json!({"row": 1, "section": "footer"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_selects_a_row_of_a_named_table_outside_the_selection() {
|
||||
let mut s = session();
|
||||
let t = s.execute("table.get", &json!({})).unwrap();
|
||||
let other = s.execute("frame.create", &json!({"rect": [72, 720, 500, 760], "content": "text", "text": "Notes"})).unwrap();
|
||||
// A click in the other frame: the caret is shown in that frame.
|
||||
s.execute("text.placeCaret", &json!({"frame": other["id"], "point": [80, 730]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0), other["id"].as_u64());
|
||||
assert!(s.execute("table.goToRow", &json!({"row": 2})).is_err(), "no table named and none selected");
|
||||
s.execute("table.goToRow", &json!({"story": t["story"], "table": t["table"], "row": 2})).unwrap();
|
||||
assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 });
|
||||
let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0);
|
||||
assert_ne!(frame, other["id"].as_u64(), "the caret is not left in the other story's frame");
|
||||
// table.select naming the table from another story does not keep that story's frame either.
|
||||
s.execute("text.placeCaret", &json!({"frame": other["id"], "point": [80, 730]})).unwrap();
|
||||
s.execute("table.select", &json!({"story": t["story"], "table": t["table"], "what": "table"})).unwrap();
|
||||
let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0);
|
||||
assert_ne!(frame, other["id"].as_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_names_the_page_that_shows_the_row() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
|
||||
let b = s.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap();
|
||||
s.execute("text.thread", &json!({"from": a["id"], "to": b["id"]})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap();
|
||||
for row in 0..21 {
|
||||
s.execute("table.setRowHeight", &json!({"row": row, "height": 20, "mode": "exactly"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.execute("table.goToRow", &json!({"row": 1})).unwrap()["page"], 1);
|
||||
assert_eq!(s.execute("table.goToRow", &json!({"row": 20})).unwrap()["page"], 2);
|
||||
let frame = s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0);
|
||||
assert_eq!(frame, b["id"].as_u64(), "the caret is in the frame that shows the row");
|
||||
// The row on its spread: one row tall, in the frame on the second spread.
|
||||
let r = s.execute("table.goToRow", &json!({"row": 20})).unwrap();
|
||||
assert_eq!(r["spread"], json!({"kind": "doc", "index": 1}));
|
||||
let at: Vec<f64> = r["rowBounds"].as_array().map(|a| a.iter().filter_map(serde_json::Value::as_f64).collect()).unwrap_or_default();
|
||||
assert!(at.len() == 4 && (at[3] - at[1] - 20.0).abs() < 1e-6 && at[0] >= 72.0 && at[2] <= 401.0 && at[1] >= 72.0 && at[3] <= 600.0, "{at:?}");
|
||||
// The header row repeats on page 2; the row itself is the one on page 1.
|
||||
assert_eq!(s.execute("table.goToRow", &json!({"row": 1, "section": "header"})).unwrap()["page"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_leaves_no_caret_frame_for_an_overset_row() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 20, "cols": 2})).unwrap();
|
||||
for row in 0..20 {
|
||||
s.execute("table.setRowHeight", &json!({"row": row, "height": 20, "mode": "exactly"})).unwrap();
|
||||
}
|
||||
let frame = |s: &Session| s.doc().unwrap().selection.text.and_then(|t| t.frame).map(|f| f.0);
|
||||
s.execute("table.goToRow", &json!({"row": 1})).unwrap();
|
||||
assert_eq!(frame(&s), a["id"].as_u64());
|
||||
let r = s.execute("table.goToRow", &json!({"row": 20})).unwrap();
|
||||
assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(null), json!(null)), "{r}");
|
||||
assert_eq!(frame(&s), None, "no frame shows the overset row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_puts_the_caret_in_a_merged_cell_that_covers_the_row() {
|
||||
let mut s = session();
|
||||
// Body rows 1 and 2 (table rows 1 and 2) share one merged cell in column 0.
|
||||
s.execute("table.merge", &json!({"rows": [1, 2], "col": 0})).unwrap();
|
||||
s.execute("table.goToRow", &json!({"row": 2})).unwrap();
|
||||
assert_eq!(selected(&s), CellRange { r0: 2, c0: 0, r1: 2, c1: 1 });
|
||||
assert_eq!(s.doc().unwrap().selection.text.and_then(|t| t.cell).map(|c| (c.row, c.col)), Some((1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_bounds_a_row_inside_cells_merged_across_rows() {
|
||||
let mut s = session();
|
||||
for (row, height) in [20, 10, 40, 20, 20].into_iter().enumerate() {
|
||||
s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap();
|
||||
}
|
||||
// Every column's cell covers body rows 1 to 3 (table rows 1 to 3), so no cell starts in body row 2.
|
||||
s.execute("table.merge", &json!({"rows": [1, 3], "cols": [0, 0]})).unwrap();
|
||||
s.execute("table.merge", &json!({"rows": [1, 3], "cols": [1, 1]})).unwrap();
|
||||
let bounds = |s: &mut Session, p: serde_json::Value| -> Vec<f64> {
|
||||
let r = s.execute("table.goToRow", &p).unwrap();
|
||||
r["rowBounds"].as_array().map(|a| a.iter().filter_map(serde_json::Value::as_f64).collect()).unwrap_or_default()
|
||||
};
|
||||
let header = bounds(&mut s, json!({"row": 1, "section": "header"}));
|
||||
let row = bounds(&mut s, json!({"row": 2}));
|
||||
assert_eq!((header.len(), row.len()), (4, 4));
|
||||
assert!((row[1] - (header[3] + 10.0)).abs() < 1e-6 && (row[3] - row[1] - 40.0).abs() < 1e-6, "{header:?} {row:?}");
|
||||
assert_eq!((row[0], row[2]), (header[0], header[2]), "the row spans the table across");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_finds_a_row_in_an_anchored_text_frame() {
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject, Content, Item, ItemId, Shape, Story, StoryId, TextFrame};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let host = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": "Host text"})).unwrap();
|
||||
// A text frame anchored in the host's text, its own story holding the table.
|
||||
let sid = {
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let (fid, sid) = (ItemId(d.alloc()), StoryId(d.alloc()));
|
||||
let mut story = Story::with_text(sid, "", Default::default());
|
||||
story.frames.push(fid);
|
||||
d.stories.insert(sid, std::sync::Arc::new(story));
|
||||
let layer = d.item(ItemId(host["id"].as_u64().unwrap())).unwrap().layer;
|
||||
let mut it =
|
||||
Item::new(fid, layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0)));
|
||||
it.content = Content::Text(TextFrame { story: sid, options: Default::default() });
|
||||
let host_story = StoryId(host["story"].as_u64().unwrap());
|
||||
d.story_mut(host_story).unwrap().insert_object(4, AnchoredObject::new(it, AnchorPosition::default()));
|
||||
sid
|
||||
};
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap();
|
||||
assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(1), json!({"kind": "doc", "index": 0})), "{r}");
|
||||
assert_eq!(r["rowBounds"].as_array().map(Vec::len), Some(4), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_finds_a_row_on_a_parent_spread() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s
|
||||
.execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 72, 400, 400], "content": "text", "text": ""}))
|
||||
.unwrap();
|
||||
s.execute("text.select", &json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let r = s.execute("table.goToRow", &json!({"row": 2})).unwrap();
|
||||
assert_eq!((r["page"].clone(), r["spread"].clone()), (json!(null), json!({"kind": "parent", "index": 0})), "{r}");
|
||||
assert_eq!(r["rowBounds"].as_array().map(Vec::len), Some(4), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_get_without_cells_reads_the_rows_alone() {
|
||||
// The Go to Row dialog reads the row counts and the selection, not every cell's text.
|
||||
let mut s = session();
|
||||
s.execute("table.goToRow", &json!({"row": 2})).unwrap();
|
||||
let t = s.execute("table.get", &json!({"cells": false})).unwrap();
|
||||
assert!(t.get("cells").is_none(), "{t}");
|
||||
assert_eq!((t["headerRows"].clone(), t["footerRows"].clone(), t["range"]["r0"].clone()), (json!(1), json!(1), json!(2)));
|
||||
assert_eq!(t["rows"].as_array().map(Vec::len), Some(5));
|
||||
assert_eq!(s.execute("table.get", &json!({})).unwrap()["cells"].as_array().map(Vec::len), Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_names_the_page_under_the_row_in_a_frame_across_the_spine() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap();
|
||||
// Pages 2 and 3 meet at x = 612; the frame's center is on page 3, the narrow table on page 2.
|
||||
let f = s.execute("frame.create", &json!({"spread": 1, "rect": [400, 72, 900, 400], "content": "text", "text": ""})).unwrap();
|
||||
s.execute("text.select", &json!({"story": f["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
s.execute("table.setColumnWidth", &json!({"cols": [0, 1], "width": 50})).unwrap();
|
||||
assert_eq!(s.execute("table.goToRow", &json!({"row": 2})).unwrap()["page"], 2);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ pub(crate) use view::page_in_view;
|
||||
use view::present_fit;
|
||||
pub use view::{
|
||||
PageBox, apply_view_request, can_go_through_history, current_page, current_parent_page, current_slot, current_spread, current_spread_page,
|
||||
drop_point, fit, go_through_history, go_to_page, page_box, page_box_go, page_target, set_zoom, show_spread, step_spread, view_centre, zoom_at,
|
||||
drop_point, fit, go_through_history, go_to_page, go_to_spread_rect, page_box, page_box_go, page_target, set_zoom, show_spread, step_spread,
|
||||
view_centre, zoom_at,
|
||||
};
|
||||
|
||||
pub const RULER: f32 = 15.0;
|
||||
|
||||
@@ -216,6 +216,27 @@ fn centre_page(app: &mut DesignApp, abs: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll so `r`, a rectangle on spread `spread` as the canvas lays it out now, is in the middle of
|
||||
/// the view from top to bottom, and from side to side when no part of it is in view across (keeps
|
||||
/// zoom). A jump Go Back can return from.
|
||||
pub fn go_to_spread_rect(app: &mut DesignApp, spread: SpreadRef, r: DRect) {
|
||||
note_jump(app, false);
|
||||
centre_spread_rect(app, spread, r);
|
||||
note_jump(app, true);
|
||||
}
|
||||
|
||||
fn centre_spread_rect(app: &mut DesignApp, spread: SpreadRef, r: DRect) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
|
||||
let Some(b) = layout.slot(spread).map(|s| s.xf.transform_rect_bbox(r)) else { return };
|
||||
let Some(rect) = app.canvas_rect else { return };
|
||||
if let Some(v) = app.view_mut() {
|
||||
let (w, h) = (rect.width() as f64 / v.zoom, rect.height() as f64 / v.zoom);
|
||||
let x = if b.x1 > v.origin.x && b.x0 < v.origin.x + w { v.origin.x } else { b.center().x - w / 2.0 };
|
||||
v.origin = Point::new(x, b.center().y - h / 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's pages by id, in page order.
|
||||
fn page_ids(doc: &Document) -> Vec<designcraft_doc::ids::PageId> {
|
||||
doc.spreads.iter().flat_map(|s| s.pages.iter().map(|p| p.id)).collect()
|
||||
|
||||
@@ -1631,6 +1631,60 @@ fn doc_unit(app: &DesignApp) -> Unit {
|
||||
app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas)
|
||||
}
|
||||
|
||||
/// Table › Go to Row…: opens on the target table's first selected row, numbered within its header,
|
||||
/// body or footer rows.
|
||||
pub fn open_go_to_row(app: &mut DesignApp) {
|
||||
let Ok(t) = app.run("table.get", json!({"cells": false})) else { return };
|
||||
let (h, f, r) = (count_at(&t, "headerRows"), count_at(&t, "footerRows"), count_at(&t["range"], "r0"));
|
||||
let nr = t["rows"].as_array().map_or(0, Vec::len);
|
||||
let (section, n) = if r < h {
|
||||
("header", r + 1)
|
||||
} else if r >= nr.saturating_sub(f) {
|
||||
("footer", r + 1 - nr.saturating_sub(f))
|
||||
} else {
|
||||
("body", r + 1 - h)
|
||||
};
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
"goToRow",
|
||||
json!({
|
||||
"uid": app.session.active().map(|doc| doc.uid),
|
||||
"story": t["story"],
|
||||
"table": t["table"],
|
||||
"section": section,
|
||||
"row": n.to_string(),
|
||||
"hasHeader": h > 0,
|
||||
"hasFooter": f > 0
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
fn count_at(v: &Value, k: &str) -> usize {
|
||||
v[k].as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The Go to Row dialog: the section menu shows when the table has header or footer rows.
|
||||
fn go_to_row_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let tr = |s: &'static str| crate::i18n::tr(&app.ui.language, s);
|
||||
ui.horizontal(|ui| {
|
||||
if d.b("hasHeader") || d.b("hasFooter") {
|
||||
let mut opts = Vec::new();
|
||||
if d.b("hasHeader") {
|
||||
opts.push(("header", tr("Header Rows")));
|
||||
}
|
||||
opts.push(("body", tr("Body Rows")));
|
||||
if d.b("hasFooter") {
|
||||
opts.push(("footer", tr("Footer Rows")));
|
||||
}
|
||||
combo(ui, d, "section", &opts);
|
||||
}
|
||||
crate::rtl::label(ui, tr("Row"));
|
||||
text_field_named(ui, d, "row", 60.0, tr("Row"));
|
||||
});
|
||||
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
|
||||
ui.label(egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's vertical ruler unit: what a bare number means in a height field.
|
||||
fn doc_vertical_unit(app: &DesignApp) -> Unit {
|
||||
app.session.active().map(|s| s.doc.settings.vertical_units).unwrap_or(Unit::Picas)
|
||||
@@ -2999,6 +3053,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"ruby" => crate::i18n::tr(&app.ui.language, "Ruby"),
|
||||
"newDocument" => crate::i18n::tr(&app.ui.language, "New Document"),
|
||||
"frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"),
|
||||
"goToRow" => crate::i18n::tr(&app.ui.language, "Go to Row"),
|
||||
"goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"),
|
||||
"insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"),
|
||||
"insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(),
|
||||
@@ -3872,6 +3927,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
text_field(ui, &mut d, "page", 80.0);
|
||||
});
|
||||
}
|
||||
"goToRow" => go_to_row_dialog(app, ui, &mut d),
|
||||
"ruby" => style_cjk::ruby_dialog(app, ui, &mut d),
|
||||
"insertTable" => insert_table_dialog(app, ui, &mut d),
|
||||
"convertTextToTable" | "convertTableToText" => convert_table_dialog(app, ui, &mut d),
|
||||
@@ -4460,6 +4516,39 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
|
||||
)
|
||||
}
|
||||
"ruby" => app.run("type.ruby", style_cjk::ruby_params(&d)),
|
||||
"goToRow" => {
|
||||
// The story and table belong to the document the dialog was opened for: that document
|
||||
// becomes active first, and when it has closed, nothing is selected.
|
||||
let uid = d.fields.get("uid").and_then(Value::as_u64);
|
||||
if uid.is_some() && uid != app.session.active().map(|doc| doc.uid) {
|
||||
let Some(i) = app.session.documents().iter().position(|doc| Some(doc.uid) == uid) else { return Ok(Value::Null) };
|
||||
app.run("file.activate", json!({"index": i}))?;
|
||||
}
|
||||
// Anything but a whole number goes as typed, and the command's message names the fault.
|
||||
let typed = d.s("row").trim().to_string();
|
||||
let row = typed.parse::<u64>().map_or_else(|_| json!(typed), |n| json!(n));
|
||||
let p = json!({"story": d.fields.get("story"), "table": d.fields.get("table"), "section": d.s("section"), "row": row});
|
||||
let r = app.run("table.goToRow", p);
|
||||
match &r {
|
||||
Ok(v) => {
|
||||
// The view scrolls the row to its middle, at the same zoom, on the row's page or another.
|
||||
let spread = serde_json::from_value::<designcraft_doc::SpreadRef>(v["spread"].clone()).ok();
|
||||
let bounds = v["rowBounds"].as_array().and_then(|a| {
|
||||
let n: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
(n.len() == 4).then(|| designcraft_geom::Rect::new(n[0], n[1], n[2], n[3]))
|
||||
});
|
||||
if let (Some(spread), Some(b)) = (spread, bounds) {
|
||||
crate::canvas::go_to_spread_rect(app, spread, b);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let mut d = d.clone();
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
"insertPages" => {
|
||||
let n = app.session.active().map_or(1, |st| st.doc.page_count());
|
||||
// The page the menu shows: a page past the end is the last.
|
||||
|
||||
@@ -4098,6 +4098,113 @@ fn create_guides_dialog_covers_the_spread_in_view() {
|
||||
assert_eq!(guide_counts(&app), vec![0, 2, 2, 0, 4, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_dialog_selects_the_row_and_shows_its_page() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)));
|
||||
let a = app.run("frame.create", json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
|
||||
let b = app.run("frame.create", json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap();
|
||||
app.run("text.thread", json!({"from": a["id"], "to": b["id"]})).unwrap();
|
||||
app.run("text.select", json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
app.run("table.insert", json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap();
|
||||
for row in 0..21 {
|
||||
app.run("table.setRowHeight", json!({"row": row, "height": 20, "mode": "exactly"})).unwrap();
|
||||
}
|
||||
app.run("table.select", json!({"rows": [2, 2]})).unwrap();
|
||||
crate::canvas::go_to_page(&mut app, 0);
|
||||
crate::menus::activate(&mut app, "table.goToRow", &Value::Null);
|
||||
let d = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(d.id, "goToRow");
|
||||
assert_eq!((d.s("section"), d.s("row")), ("body".to_string(), "2".to_string()), "it opens on the selected row");
|
||||
assert!(d.b("hasHeader") && !d.b("hasFooter"));
|
||||
// A row that is not a whole number keeps the dialog open with the reason, and the view stays.
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "x"}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| d.id == "goToRow" && d.fields.contains_key("status")));
|
||||
assert_eq!(crate::canvas::current_page(&app), Some(0));
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": "20"}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let range = app.session.active().unwrap().selection.cells.unwrap().range;
|
||||
assert_eq!((range.r0, range.r1, range.c0, range.c1), (20, 20, 0, 1));
|
||||
assert_eq!(crate::canvas::current_page(&app), Some(1), "the view goes to the page that shows the row");
|
||||
}
|
||||
|
||||
/// Go to Row's OK scrolls the row to the middle of the view at the same zoom: a row off screen on the
|
||||
/// page in view, and a row on another page away from that page's center.
|
||||
#[test]
|
||||
fn go_to_row_dialog_scrolls_the_row_to_the_middle_of_the_view() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)));
|
||||
let a = app.run("frame.create", json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap();
|
||||
let b = app.run("frame.create", json!({"spread": 1, "rect": [72, 72, 400, 600], "content": "unassigned"})).unwrap();
|
||||
app.run("text.thread", json!({"from": a["id"], "to": b["id"]})).unwrap();
|
||||
app.run("text.select", json!({"story": a["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
app.run("table.insert", json!({"rows": 20, "cols": 2, "headerRows": 1})).unwrap();
|
||||
for row in 0..21 {
|
||||
app.run("table.setRowHeight", json!({"row": row, "height": 20, "mode": "exactly"})).unwrap();
|
||||
}
|
||||
app.run("table.select", json!({"rows": [1, 1]})).unwrap();
|
||||
crate::canvas::go_to_page(&mut app, 0);
|
||||
crate::canvas::set_zoom(&mut app, 4.0);
|
||||
// The row's middle and the view's middle, from top to bottom, on the canvas.
|
||||
let middles = |app: &mut DesignApp, row: u64| {
|
||||
let r = app.run("table.goToRow", json!({"row": row})).unwrap();
|
||||
let spread: designcraft_doc::SpreadRef = serde_json::from_value(r["spread"].clone()).unwrap();
|
||||
let n: Vec<f64> = r["rowBounds"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
|
||||
let st = app.session.active().unwrap();
|
||||
let slot = designcraft_tools::CanvasLayout::new(&st.doc, false).slot(spread).unwrap().xf;
|
||||
let row_y = slot.transform_rect_bbox(designcraft_geom::Rect::new(n[0], n[1], n[2], n[3])).center().y;
|
||||
let (v, rect) = (app.view().unwrap(), app.canvas_rect.unwrap());
|
||||
(row_y, v.origin.y + rect.height() as f64 / 2.0 / v.zoom)
|
||||
};
|
||||
let x = app.view().unwrap().origin.x;
|
||||
for row in ["3", "20"] {
|
||||
crate::menus::activate(&mut app, "table.goToRow", &Value::Null);
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "row", "value": row}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||||
let (row_y, view_y) = middles(&mut app, row.parse().unwrap());
|
||||
assert!((row_y - view_y).abs() < 0.5, "row {row} is in the middle of the view: {row_y} against {view_y}");
|
||||
let v = app.view().unwrap();
|
||||
assert!((v.zoom - 4.0).abs() < 1e-9, "the zoom stays");
|
||||
assert!((v.origin.x - x).abs() < 1e-9, "the row was in view across, so the view does not scroll sideways");
|
||||
}
|
||||
assert_eq!(crate::canvas::current_page(&app), Some(1));
|
||||
}
|
||||
|
||||
/// Go to Row's OK goes to the row of the table in the document the dialog was opened for, even
|
||||
/// when another document became active meanwhile; when that document has closed, nothing is selected.
|
||||
#[test]
|
||||
fn go_to_row_dialog_goes_to_the_document_it_was_opened_for() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
let table_doc = |app: &mut DesignApp, title: &str| {
|
||||
app.run("file.new", json!({"title": title})).unwrap();
|
||||
let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap();
|
||||
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
app.run("table.insert", json!({"rows": 3, "cols": 2})).unwrap();
|
||||
};
|
||||
table_doc(&mut app, "first");
|
||||
crate::menus::activate(&mut app, "table.goToRow", &Value::Null);
|
||||
table_doc(&mut app, "second");
|
||||
setup_fields(&mut app, json!({"row": "3"}));
|
||||
confirm(&mut app).unwrap();
|
||||
let active = app.session.active().unwrap();
|
||||
assert_eq!(active.doc.title, "first", "the dialog's document becomes active");
|
||||
assert_eq!(active.selection.cells.map(|c| (c.range.r0, c.range.r1)), Some((2, 2)));
|
||||
let second = app.session.documents().iter().find(|d| d.doc.title == "second").unwrap();
|
||||
assert!(second.selection.cells.is_none(), "the other document's selection stays");
|
||||
|
||||
crate::menus::activate(&mut app, "table.goToRow", &Value::Null);
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
let before = app.session.active().unwrap().selection.clone();
|
||||
setup_fields(&mut app, json!({"row": "1"}));
|
||||
assert_eq!(confirm(&mut app), Ok(Value::Null));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.session.active().unwrap().selection, before, "the dialog's document closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_dialogs_cover_both_pages_of_the_parent_spread_by_default() {
|
||||
let mut app = facing_app(4);
|
||||
|
||||
@@ -300,6 +300,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Numbering & Section Options…", "خيارات الترقيم والأقسام…"),
|
||||
("Go to Page…", "انتقال إلى صفحة…"),
|
||||
("Go to Page", "انتقال إلى صفحة"),
|
||||
("Go to Row…", "انتقال إلى صف…"),
|
||||
("Go to Row", "انتقال إلى صف"),
|
||||
("Next Page", "الصفحة التالية"),
|
||||
("Previous Page", "الصفحة السابقة"),
|
||||
("First Page", "الصفحة الأولى"),
|
||||
|
||||
@@ -679,6 +679,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "Přejít na zdroj"),
|
||||
("Go to Footnote Reference", "Přejít na odkaz poznámky pod čarou"),
|
||||
("Go to Page…", "Přejít na stránku…"),
|
||||
("Go to Row…", "Přejít na řádek…"),
|
||||
("Go to Row", "Přejít na řádek"),
|
||||
("Gradient Feather", "Přechodové zjemnění okraje"),
|
||||
("Gridify", "Rozmístit do mřížky"),
|
||||
("Group Transparency", "Průhlednost skupiny"),
|
||||
|
||||
@@ -305,6 +305,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Generate QR Code", "QR-Code erzeugen"),
|
||||
("Go To", "Gehe zu"),
|
||||
("Go to Page", "Gehe zu Seite"),
|
||||
("Go to Row", "Gehe zu Zeile"),
|
||||
("Gutter", "Spaltenabstand"),
|
||||
("Header Rows", "Tabellenkopfzeilen"),
|
||||
("Height", "Höhe"),
|
||||
@@ -642,6 +643,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "Gehe zu Quelle"),
|
||||
("Go to Footnote Reference", "Gehe zu Fußnotenverweis"),
|
||||
("Go to Page…", "Gehe zu Seite …"),
|
||||
("Go to Row…", "Gehe zu Zeile …"),
|
||||
("Gradient Feather", "Weiche Verlaufskante"),
|
||||
("Gridify", "Rastern"),
|
||||
("Group Transparency", "Gruppentransparenz"),
|
||||
|
||||
@@ -810,6 +810,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "Vai all'origine"),
|
||||
("Go to Footnote Reference", "Vai al riferimento della nota a piè di pagina"),
|
||||
("Go to Page…", "Vai alla pagina…"),
|
||||
("Go to Row…", "Vai alla riga…"),
|
||||
("Go to Row", "Vai alla riga"),
|
||||
("Gradient Feather", "Bordi sfocati con sfumatura"),
|
||||
("Gridify", "Disponi a griglia"),
|
||||
("Group Transparency", "Trasparenza gruppo"),
|
||||
|
||||
@@ -488,6 +488,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Generate QR Code", "QR コードを生成"),
|
||||
("Go To", "移動"),
|
||||
("Go to Page", "ページへ移動"),
|
||||
("Go to Row", "行へ移動"),
|
||||
("Gutter", "段間"),
|
||||
("Header Rows", "ヘッダー行"),
|
||||
("Height", "高さ"),
|
||||
@@ -832,6 +833,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "リンク元へ移動"),
|
||||
("Go to Footnote Reference", "脚注参照番号へ移動"),
|
||||
("Go to Page…", "ページへ移動…"),
|
||||
("Go to Row…", "行へ移動…"),
|
||||
("Gradient Feather", "グラデーションぼかし"),
|
||||
("Gridify", "グリッド化"),
|
||||
("Group Transparency", "グループの透明"),
|
||||
|
||||
@@ -729,6 +729,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "წყაროზე გადასვლა"),
|
||||
("Go to Footnote Reference", "სქოლიოს მითითებაზე გადასვლა"),
|
||||
("Go to Page…", "გვერდზე გადასვლა…"),
|
||||
("Go to Row…", "სტრიქონზე გადასვლა…"),
|
||||
("Go to Row", "სტრიქონზე გადასვლა"),
|
||||
("Gradient Feather", "კიდეების გრადიენტული შერბილება"),
|
||||
("Gridify", "ბადედ განლაგება"),
|
||||
("Group Transparency", "ჯგუფის გამჭვირვალობა"),
|
||||
|
||||
@@ -689,6 +689,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "Przejdź do źródła"),
|
||||
("Go to Footnote Reference", "Przejdź do odwołania przypisu"),
|
||||
("Go to Page…", "Przejdź do strony…"),
|
||||
("Go to Row…", "Przejdź do wiersza…"),
|
||||
("Go to Row", "Przejdź do wiersza"),
|
||||
("Gradient Feather", "Wtapianie gradientowe"),
|
||||
("Gridify", "Rozmieść w siatce"),
|
||||
("Group Transparency", "Przezroczystość grupy"),
|
||||
|
||||
@@ -311,6 +311,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Create Guides…", "Criar guias…"),
|
||||
("Numbering & Section Options…", "Opções de numeração e de seção…"),
|
||||
("Go to Page…", "Ir para página…"),
|
||||
("Go to Row…", "Ir para linha…"),
|
||||
("Go to Row", "Ir para linha"),
|
||||
("Go to Page", "Ir para página"),
|
||||
("Next Page", "Próxima página"),
|
||||
("Previous Page", "Página anterior"),
|
||||
|
||||
@@ -669,6 +669,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "Перейти к источнику"),
|
||||
("Go to Footnote Reference", "Перейти к ссылке на сноску"),
|
||||
("Go to Page…", "Перейти к странице…"),
|
||||
("Go to Row…", "Перейти к строке…"),
|
||||
("Go to Row", "Перейти к строке"),
|
||||
("Gradient Feather", "Градиентная растушевка"),
|
||||
("Gridify", "Разместить в виде сетки"),
|
||||
("Group Transparency", "Прозрачность группы"),
|
||||
|
||||
@@ -103,6 +103,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Numbering & Section Options…", "Numrerings- och avsnittsalternativ…"),
|
||||
("Go to Page…", "Gå till sida…"),
|
||||
("Go to Page", "Gå till sida"),
|
||||
("Go to Row…", "Gå till rad…"),
|
||||
("Go to Row", "Gå till rad"),
|
||||
("Next Page", "Nästa sida"),
|
||||
("Previous Page", "Föregående sida"),
|
||||
("First Page", "Första sidan"),
|
||||
|
||||
@@ -1254,6 +1254,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Generate QR Code", ["Generate QR Code", "Générer un code QR", "Generate QR Code", "Generate QR Code", "生成二维码"]),
|
||||
("Go To", ["Go To", "Atteindre", "Go To", "Go To", "转到"]),
|
||||
("Go to Page", ["Go to Page", "Atteindre la page", "Go to Page", "Go to Page", "转到页面"]),
|
||||
("Go to Row", ["Go to Row", "Atteindre la ligne", "Go to Row", "Go to Row", "转到行"]),
|
||||
("Gutter", ["Gutter", "Gouttière", "Gutter", "Gutter", "栏间距"]),
|
||||
("Header Rows", ["Header Rows", "Lignes d'en-tête", "Header Rows", "Header Rows", "表头行"]),
|
||||
("Height", ["Height", "Hauteur", "Height", "Height", "高度"]),
|
||||
@@ -2255,6 +2256,7 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
|
||||
],
|
||||
),
|
||||
("Go to Page…", ["Go to Page…", "Atteindre la page…", "Go to Page…", "Go to Page…", "转到页面…"]),
|
||||
("Go to Row…", ["Go to Row…", "Atteindre la ligne…", "Go to Row…", "Go to Row…", "转到行…"]),
|
||||
("Gradient Feather", ["Gradient Feather", "Contour progressif dégradé", "Gradient Feather", "Gradient Feather", "渐变羽化"]),
|
||||
("Gridify", ["Gridify", "Mettre en grille", "Gridify", "Gridify", "网格化"]),
|
||||
("Group Transparency", ["Group Transparency", "Transparence du groupe", "Group Transparency", "Group Transparency", "编组透明度"]),
|
||||
|
||||
@@ -813,6 +813,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Go To Source", "Перейти до джерела"),
|
||||
("Go to Footnote Reference", "Перейти до посилання на виноску"),
|
||||
("Go to Page…", "Перейти до сторінки…"),
|
||||
("Go to Row…", "Перейти до рядка…"),
|
||||
("Go to Row", "Перейти до рядка"),
|
||||
("Gradient Feather", "Градієнтне розтушовування"),
|
||||
("Gridify", "Розмістити сіткою"),
|
||||
("Group Transparency", "Прозорість групи"),
|
||||
|
||||
@@ -83,6 +83,10 @@ fn activate_inner(app: &mut DesignApp, id: &str, params: &Value) {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({})));
|
||||
return;
|
||||
}
|
||||
if params.is_null() && id == "table.goToRow" {
|
||||
crate::dialogs::open_go_to_row(app);
|
||||
return;
|
||||
}
|
||||
// New Paragraph/Character Style…: the style options for the new style.
|
||||
if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") {
|
||||
crate::dialogs::open_new_style(app, id == "style.paragraph.create");
|
||||
|
||||
@@ -175,6 +175,10 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
|
||||
if id.starts_with("fit.") {
|
||||
return enabled(app, "object.fit");
|
||||
}
|
||||
// Go to Row runs on a named table from anywhere; the menu item needs the selection in a table.
|
||||
if id == "table.goToRow" {
|
||||
return enabled(app, "table.selectColumn");
|
||||
}
|
||||
if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -469,6 +469,8 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"-",
|
||||
"cmd:table.distributeRows",
|
||||
"cmd:table.distributeColumns",
|
||||
"-",
|
||||
"cmd:table.goToRow",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -323,6 +323,17 @@ fn transform_menu_items_open_their_dialogs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_row_is_enabled_in_a_table() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let r = app.run("frame.create", json!({"rect": [72, 72, 500, 700], "content": "text", "text": "Notes"})).unwrap();
|
||||
app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
assert!(!menu_enabled(&app, "table.goToRow"), "the insertion point is not in a table");
|
||||
app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap();
|
||||
assert!(menu_enabled(&app, "table.goToRow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_menu_item_opens_the_dialog_or_exports() {
|
||||
// No params: the menu item opens the options dialog.
|
||||
@@ -2373,6 +2384,8 @@ fn the_table_menu_follows_indesigns_order() {
|
||||
"-",
|
||||
"Distribute Rows Evenly",
|
||||
"Distribute Columns Evenly",
|
||||
"-",
|
||||
"Go to Row…",
|
||||
];
|
||||
assert_eq!(labels, expected);
|
||||
// Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of
|
||||
|
||||
+2
-2
@@ -154,8 +154,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
|
||||
### 12. Tables in depth (F)
|
||||
- **Missing:** anchored objects in cells not drawn (#163); Table ▸ Table Options and Cell Options
|
||||
submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer,
|
||||
Go to Row; general reliability (#20).
|
||||
submenus as InDesign lays them out, Diagonal Lines, Edit Header/Footer; general reliability
|
||||
(#20).
|
||||
- **Estimate:** 25–40 h.
|
||||
|
||||
### 13. Hyphenation and spelling languages (T, L)
|
||||
|
||||
Reference in new issue
Block a user