mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-12 00:41:12 -06:00
Merge remote-tracking branch 'origin/main' into codex/page-tool-liquid-guides
This commit is contained in:
+287
-13
@@ -20,13 +20,6 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.clipboard.is_some() || text { Ok(()) } else { Err("clipboard is empty".into()) }
|
||||
}
|
||||
|
||||
fn has_clip_or_text(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
return Ok(());
|
||||
}
|
||||
has_clip(s)
|
||||
}
|
||||
|
||||
fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().and_then(|d| d.selection.text).is_some_and(|t| !t.is_caret()) {
|
||||
return Ok(());
|
||||
@@ -74,7 +67,7 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
|
||||
}
|
||||
}
|
||||
let use_clip = formatted && clip.as_ref().is_some_and(|(_, p)| text.as_ref().is_none_or(|x| x.replace("\r\n", "\n") == *p));
|
||||
// Footnotes, cross-references and tables only paste into story text, not cells or footnotes.
|
||||
// Footnotes, endnotes, editorial notes, cross-references and tables cannot be pasted into cells or footnotes.
|
||||
if use_clip && let Some((slice, _)) = clip {
|
||||
let slice = if t.cell.is_some() { strip_objects(&slice) } else { (*slice).clone() };
|
||||
return s.edit(|d, sel| {
|
||||
@@ -88,12 +81,84 @@ fn paste_text(s: &mut Session, text: Option<String>, formatted: bool) -> Result<
|
||||
});
|
||||
}
|
||||
let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?;
|
||||
s.execute("text.insert", &json!({"text": text.replace("\r\n", "\n").replace('\r', "\n"), "raw": true}))
|
||||
let text = outside_text(&text)?;
|
||||
s.execute("text.insert", &json!({"text": text, "raw": true}))
|
||||
}
|
||||
|
||||
/// Text from the system clipboard as story text: Unix line ends, and none of the private-use
|
||||
/// characters (U+E000–U+E1FF) that stories use as markers for breaks, footnotes, tables and
|
||||
/// variables, since a stray one would be read as a marker without its payload.
|
||||
fn outside_text(text: &str) -> Result<String> {
|
||||
let text: String = text.replace("\r\n", "\n").replace('\r', "\n").chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).collect();
|
||||
if text.is_empty() { Err(super::bad("edit.paste", "the clipboard holds no text")) } else { Ok(text) }
|
||||
}
|
||||
|
||||
/// Pasted text with no insertion point: a new text frame holding it, as wide as the first column
|
||||
/// of the page's margin area and as tall as the text (at most the margin area's height), centred
|
||||
/// on `at` (spread coordinates) or else on the margin area. The frame ends up selected. With
|
||||
/// `slice` (the text copied here), the frame's story is that formatted copy.
|
||||
fn paste_text_frame(s: &mut Session, p: &Value, text: &str, slice: Option<&designcraft_doc::Story>) -> Result<Value> {
|
||||
use designcraft_geom::{Point, Rect, shapes};
|
||||
const LIMIT: f64 = 1.0e5;
|
||||
let sr = super::spread_param(p, "spread")?;
|
||||
let at = super::point_param(p, "at")
|
||||
.filter(|a| a.x.is_finite() && a.y.is_finite())
|
||||
.map(|a| Point::new(a.x.clamp(-LIMIT, LIMIT), a.y.clamp(-LIMIT, LIMIT)));
|
||||
let lid = s.doc()?.active_layer;
|
||||
let cache = s.cache.clone();
|
||||
let text = if slice.is_some() { String::new() } else { outside_text(text)? };
|
||||
s.edit(|d, sel| {
|
||||
let sp = d.spread(sr).ok_or_else(|| super::bad("edit.paste", "no such spread"))?;
|
||||
let page = at.and_then(|a| sp.page_at_x(a.x)).and_then(|i| sp.pages.get(i)).or_else(|| sp.pages.first());
|
||||
let page = page.ok_or_else(|| super::bad("edit.paste", "the spread has no pages"))?;
|
||||
let margins = page.margin_rect();
|
||||
let column = page.column_rects().first().copied().unwrap_or(margins);
|
||||
let w = column.width().max(12.0);
|
||||
let max_h = margins.height().max(12.0);
|
||||
let c = at.unwrap_or(Point::new(column.center().x, margins.center().y));
|
||||
let rect = |h: f64| Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0);
|
||||
let para = match slice.and_then(|sl| sl.paras.first()) {
|
||||
Some(pf) => designcraft_doc::ParaFormat { table: None, ..pf.clone() },
|
||||
None => designcraft_doc::ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() },
|
||||
};
|
||||
let (id, sid) = d.add_text_frame(sr, rect(max_h), lid, &text, para)?;
|
||||
if let Some(sl) = slice {
|
||||
d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?.insert_story(0, sl);
|
||||
}
|
||||
// Shrink to the composed text unless it already fills the frame.
|
||||
let composed = cache.get(d, sid, None);
|
||||
if !composed.is_overset()
|
||||
&& let Some(ft) = composed.frame(id)
|
||||
{
|
||||
let inset = d.item(id).and_then(|i| i.text_frame()).map_or(0.0, |tf| tf.options.inset[0] + tf.options.inset[2]);
|
||||
let h = (ft.content_height + inset + 0.5).clamp(12.0, max_h);
|
||||
if h.is_finite()
|
||||
&& h < max_h
|
||||
&& let Some(it) = d.item_mut(id)
|
||||
{
|
||||
it.path = shapes::rectangle(rect(h));
|
||||
// Keep the full height if the last line no longer fits.
|
||||
if cache.get(d, sid, None).is_overset()
|
||||
&& let Some(it) = d.item_mut(id)
|
||||
{
|
||||
it.path = shapes::rectangle(rect(max_h));
|
||||
}
|
||||
}
|
||||
}
|
||||
*sel = Selection::items(vec![id]);
|
||||
Ok(json!({"id": id.0, "story": sid.0}))
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_objects(st: &designcraft_doc::Story) -> designcraft_doc::Story {
|
||||
let mut out = st.clone();
|
||||
let marks = [designcraft_doc::FOOTNOTE_REF, designcraft_doc::XREF_MARK, designcraft_doc::TABLE_ANCHOR];
|
||||
let marks = [
|
||||
designcraft_doc::FOOTNOTE_REF,
|
||||
designcraft_doc::ENDNOTE_REF,
|
||||
designcraft_doc::NOTE_MARK,
|
||||
designcraft_doc::XREF_MARK,
|
||||
designcraft_doc::TABLE_ANCHOR,
|
||||
];
|
||||
while let Some(i) = out.text.find(marks) {
|
||||
let n = out.text[i..].chars().next().map_or(1, char::len_utf8);
|
||||
out.delete(i..i + n);
|
||||
@@ -110,6 +175,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.revision += 1;
|
||||
// Pending typing overrides belong to the current edit state, not a restored snapshot.
|
||||
s.typing_format = None;
|
||||
Ok(json!({"undone": e.label}))
|
||||
}),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| {
|
||||
@@ -119,6 +186,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.revision += 1;
|
||||
s.typing_format = None;
|
||||
Ok(json!({"redone": e.label}))
|
||||
}),
|
||||
cmd!(noundo "selection.set", "Select", [], None, "{ids: [id], add?: bool, content?: bool}", has_doc, |s, p| {
|
||||
@@ -227,11 +295,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paste",
|
||||
["Edit"],
|
||||
Some("Cmd+V"),
|
||||
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here)}",
|
||||
has_clip_or_text,
|
||||
"{inPlace?: bool, text?: the system clipboard's text (pastes it unless it is what was copied here; with no insertion point it becomes a new text frame), spread?, at?: [x, y] (spread coords: the new text frame's centre)}",
|
||||
// The system clipboard may hold text from another app, so Paste is always available.
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let text = super::str_param(p, "text").filter(|t| !t.is_empty());
|
||||
if s.doc()?.selection.text.is_some() {
|
||||
return paste_text(s, super::str_param(p, "text").map(str::to_string), true);
|
||||
return paste_text(s, text.map(str::to_string), true);
|
||||
}
|
||||
if let Some(text) = text {
|
||||
// What our own text copy put on the clipboard keeps its formatting.
|
||||
let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice);
|
||||
return paste_text_frame(s, p, text, slice.as_deref());
|
||||
}
|
||||
let Some(clip) = s.clipboard.clone() else {
|
||||
return Err(super::bad("edit.paste", "clipboard is empty"));
|
||||
@@ -316,6 +391,87 @@ mod text_clipboard_tests {
|
||||
s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_text_with_no_insertion_point_makes_a_text_frame() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
|
||||
// Text from another app, nothing copied here: a new, selected text frame.
|
||||
let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap();
|
||||
let id = ItemId(r["id"].as_u64().unwrap());
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.selection.items, vec![id]);
|
||||
assert!(d.selection.text.is_none(), "selected as an object");
|
||||
let it = d.doc.item(id).unwrap();
|
||||
assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line");
|
||||
let page = &d.doc.spreads[0].pages[0];
|
||||
let (b, m) = (it.bounds(), page.margin_rect());
|
||||
assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}");
|
||||
assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}");
|
||||
assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}");
|
||||
assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(items(&s), 0, "one undo removes it");
|
||||
// Centred on `at`; hostile numbers are clamped.
|
||||
let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap();
|
||||
let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds();
|
||||
assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}");
|
||||
assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err());
|
||||
s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap();
|
||||
// Copied objects paste when no text is given.
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone();
|
||||
s.execute("selection.set", &json!({"ids": [rect]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
let n = items(&s);
|
||||
let r = s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(items(&s), n + 1);
|
||||
assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none());
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_text_never_pastes_markers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// Private-use characters from another app would read as footnote, table or variable markers.
|
||||
let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}";
|
||||
let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap();
|
||||
let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}");
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
let b = frame(&mut s, "Target");
|
||||
s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap();
|
||||
s.execute("edit.paste", &json!({"text": dirty})).unwrap();
|
||||
s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}");
|
||||
assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err());
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = frame(&mut s, "Bold words\nSecond");
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap();
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap();
|
||||
s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap();
|
||||
s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap();
|
||||
let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
s.execute("edit.deselectAll", &json!({})).unwrap();
|
||||
let r = s.execute("edit.paste", &json!({"text": copied})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap();
|
||||
assert_eq!(st.text, "Bold words\nSecond");
|
||||
assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold"));
|
||||
assert_eq!(st.char_format_at(7).over.font_style, None);
|
||||
assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]);
|
||||
d.doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatted_copy_paste_and_plain_paste() {
|
||||
let mut s = Session::new();
|
||||
@@ -355,6 +511,124 @@ mod text_clipboard_tests {
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let source = frame(&mut s, "aéz");
|
||||
s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap();
|
||||
s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap();
|
||||
s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz");
|
||||
s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"}));
|
||||
(s, source)
|
||||
}
|
||||
|
||||
fn assert_child_note_paste(
|
||||
s: &mut Session,
|
||||
source: designcraft_doc::StoryId,
|
||||
parent: designcraft_doc::StoryId,
|
||||
cell: designcraft_doc::CellAddr,
|
||||
endpoints: (usize, usize),
|
||||
) {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selected = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1));
|
||||
assert_eq!(selected.frame, None);
|
||||
let before_child = before.text_story(parent, Some(cell)).unwrap();
|
||||
assert_eq!(before_child.text, "L----R");
|
||||
let clipboard = s.text_clipboard.clone();
|
||||
let history = s.execute("document.history", &json!({})).unwrap();
|
||||
assert_eq!(history["redo"], json!([]));
|
||||
|
||||
let result = s.execute("edit.paste", &json!({})).unwrap();
|
||||
let after = s.doc().unwrap().doc.clone();
|
||||
let child = after.text_story(parent, Some(cell)).unwrap();
|
||||
assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text");
|
||||
assert_eq!(child.len(), 6);
|
||||
assert_eq!(result, json!({"pos": 5, "formatted": true}));
|
||||
assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected }));
|
||||
assert!(child.endnotes.is_empty() && child.editorial.is_empty());
|
||||
for pos in [1, 2, 4] {
|
||||
let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap();
|
||||
assert_eq!(format.over.font_style.as_deref(), Some("Bold"));
|
||||
}
|
||||
// Inspect actual run coverage: char_format_at at a run boundary means the preceding run.
|
||||
for pos in [0, 5] {
|
||||
let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap();
|
||||
let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap();
|
||||
assert_eq!(new_format, old_format);
|
||||
}
|
||||
assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text);
|
||||
assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars);
|
||||
assert_eq!(after.story(source), before.story(source));
|
||||
assert_eq!(s.text_clipboard, clipboard);
|
||||
|
||||
let mut pasted_history = history.clone();
|
||||
pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste"));
|
||||
assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history);
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"}));
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
let mut undone_history = history;
|
||||
undone_history["redo"] = json!(["Paste"]);
|
||||
assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history);
|
||||
// This filter does not change which selection Undo restores.
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"}));
|
||||
assert_eq!(s.doc().unwrap().doc, after);
|
||||
assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() {
|
||||
let (mut s, source) = note_clipboard_session();
|
||||
let parent = frame(&mut s, "PZ");
|
||||
s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap();
|
||||
let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("text.insert", &json!({"text": "L----R"})).unwrap();
|
||||
s.execute("table.nextCell", &json!({})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap();
|
||||
s.execute("table.prevCell", &json!({})).unwrap();
|
||||
s.execute("text.move", &json!({"dir": "storyStart"})).unwrap();
|
||||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||||
for _ in 0..4 {
|
||||
s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap();
|
||||
}
|
||||
let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone();
|
||||
let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 };
|
||||
assert_child_note_paste(&mut s, source, parent, cell, (1, 5));
|
||||
let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id];
|
||||
assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR");
|
||||
let mut expected_table = (*before_table).clone();
|
||||
expected_table.cells[0].text = table.cells[0].text.clone();
|
||||
assert_eq!(table.as_ref(), &expected_table);
|
||||
|
||||
// The same clipboard must still retain both markers when pasted into an ordinary root story.
|
||||
let root = frame(&mut s, "L----R");
|
||||
s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap();
|
||||
assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true}));
|
||||
assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() {
|
||||
let (mut s, source) = note_clipboard_session();
|
||||
let parent = frame(&mut s, "PZ");
|
||||
let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap();
|
||||
let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
for _ in 0..4 {
|
||||
s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap();
|
||||
}
|
||||
let before = s.doc().unwrap().doc.story(parent).unwrap().clone();
|
||||
assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1));
|
||||
let after = s.doc().unwrap().doc.story(parent).unwrap();
|
||||
assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR");
|
||||
assert_eq!(after.note(neighbor), before.note(neighbor));
|
||||
assert_eq!(after.notes.iter().map(|note| note.id).collect::<Vec<_>>(), before.notes.iter().map(|note| note.id).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_case_keeps_runs() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -989,4 +989,24 @@ mod document_fonts_tests {
|
||||
assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_and_italic_link_the_document_fonts_styles() {
|
||||
const FAMILY: &str = "DocFont Linked";
|
||||
let dir = temp_dir("linked");
|
||||
std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap();
|
||||
let path = dir.join("Linked.designcraft");
|
||||
write_document(&path, FAMILY);
|
||||
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &path);
|
||||
let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
||||
// The family is the document's alone: its Regular is the style without bold or italic.
|
||||
let r = s.execute("type.bold", &json!({"on": false})).unwrap();
|
||||
assert_eq!(r["fontStyle"], "Regular", "{r}");
|
||||
let r = s.execute("type.italic", &json!({"on": false})).unwrap();
|
||||
assert_eq!(r["fontStyle"], "Regular", "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ mod strokes;
|
||||
mod style;
|
||||
pub mod table;
|
||||
pub mod text;
|
||||
mod thread;
|
||||
mod toc;
|
||||
mod transitions;
|
||||
mod variables;
|
||||
@@ -184,6 +185,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(package::specs());
|
||||
v.extend(print::specs());
|
||||
v.extend(text::specs());
|
||||
v.extend(thread::specs());
|
||||
v.extend(style::specs());
|
||||
v.extend(table::specs());
|
||||
v.extend(layout::specs());
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Footnote Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
|
||||
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
|
||||
has_doc,
|
||||
options
|
||||
),
|
||||
|
||||
@@ -532,7 +532,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Frame Options…",
|
||||
["Object"],
|
||||
Some("Cmd+B"),
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
"{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, autoSize?, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}",
|
||||
has_selection,
|
||||
text_frame_options
|
||||
),
|
||||
|
||||
@@ -124,19 +124,25 @@ fn fit(d: &Document, sid: StoryId) -> (Option<usize>, usize, usize) {
|
||||
}
|
||||
|
||||
/// Thread new frames on new pages (margin rectangles) until the story fits. Returns pages added.
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Result<usize> {
|
||||
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
|
||||
/// name `cmd`, the command that autoflows.
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str) -> Result<usize> {
|
||||
let last = d.story(sid).and_then(|s| s.frames.last().copied()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
|
||||
if !matches!(d.find(last).map(|l| l.spread), Some(SpreadRef::Doc(_))) {
|
||||
return Err(bad(cmd, "autoflow works only from a frame on a document page"));
|
||||
}
|
||||
let mut added = 0;
|
||||
loop {
|
||||
let (overset, held, full) = fit(d, sid);
|
||||
let Some(at) = overset else { return Ok(added) };
|
||||
if added >= max_pages {
|
||||
return Err(bad("file.place", format!("autoflow stopped after {max_pages} pages")));
|
||||
return Err(bad(cmd, format!("autoflow stopped after {max_pages} pages")));
|
||||
}
|
||||
let len = d.story(sid).map_or(0, |s| s.len());
|
||||
// Frames still needed at the rate the full ones are filled (at least one); with no full
|
||||
// frame to go by yet, one more page.
|
||||
let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).clamp(1, max_pages - added);
|
||||
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad("file.place", "the story has no frame"))?;
|
||||
let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?;
|
||||
let mut page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1));
|
||||
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
|
||||
d.insert_pages(Some(page), want, parent)?;
|
||||
@@ -144,7 +150,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize) -> Resu
|
||||
let mut prev = last;
|
||||
for _ in 0..want {
|
||||
page += 1;
|
||||
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("file.place", "page insert failed"))?;
|
||||
let (si, pi) = d.page_loc(page).ok_or_else(|| bad(cmd, "page insert failed"))?;
|
||||
let r = d.spreads[si].pages[pi].margin_rect();
|
||||
let (fid, _) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
|
||||
d.thread(prev, fid)?;
|
||||
@@ -230,7 +236,7 @@ pub(super) fn place_text(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -
|
||||
*sel = designcraft_doc::Selection::items(vec![fid]);
|
||||
sid
|
||||
};
|
||||
let pages = if autoflow_on { autoflow(d, sid, 2000)? } else { 0 };
|
||||
let pages = if autoflow_on { autoflow(d, sid, 2000, "file.place")? } else { 0 };
|
||||
let overset = fit(d, sid).0.is_some();
|
||||
Ok(json!({"story": sid.0, "pagesAdded": pages, "overset": overset, "warnings": warnings}))
|
||||
})
|
||||
|
||||
+142
-45
@@ -180,7 +180,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Color",
|
||||
[],
|
||||
None,
|
||||
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour on the selected text or objects; `text` also targets the text of selected frames",
|
||||
"{color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255), target?: fill|stroke, text?: bool, ids?} — an unnamed colour (not in the Swatches panel until Add to Swatches) on the selection; `text`: on the selected text, or the text of the selected frames (Formatting Affects Text)",
|
||||
has_text_or_selection,
|
||||
apply_color
|
||||
),
|
||||
@@ -638,10 +638,9 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(target) = super::text::format_targets_pub(s).first()
|
||||
{
|
||||
let st = s.doc()?;
|
||||
let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
|
||||
// Match paragraph fromSelection's first-character/caret policy, but keep paragraph
|
||||
// attributes inherited instead of freezing the fully resolved character properties.
|
||||
let format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) };
|
||||
let format = super::text::effective_char_format(s, target)?;
|
||||
let mut captured = st.doc.styles.char_style_attrs(&format.style);
|
||||
captured.merge(&format.over);
|
||||
// Replay the explicit fields so null clears a captured value rather than looking absent.
|
||||
@@ -986,47 +985,30 @@ fn apply_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let named = p.get("ids").is_some() || p.get("id").is_some();
|
||||
let text = !named
|
||||
&& (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some());
|
||||
if text {
|
||||
let targets = super::text::format_targets(s);
|
||||
// A caret in existing text has no editable range. Refuse it before creating a swatch
|
||||
// or history; an empty story has a format run that the next insertion inherits.
|
||||
if !targets
|
||||
.iter()
|
||||
.any(|t| !t.range.is_empty() || s.active().is_some_and(|d| d.doc.text_story(t.story, t.cell).is_some_and(|st| st.is_empty())))
|
||||
{
|
||||
return Err(bad("object.color", "select the text to colour"));
|
||||
}
|
||||
return s.edit(|d, _| {
|
||||
let name = color_swatch(d, color);
|
||||
for t in &targets {
|
||||
let Some(story) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
let set = |f: &mut designcraft_doc::CharFormat| {
|
||||
if stroke {
|
||||
(f.over.stroke, f.over.stroke_tint) = (Some(name.clone()), Some(1.0));
|
||||
} else {
|
||||
(f.over.fill, f.over.fill_tint) = (Some(name.clone()), Some(1.0));
|
||||
}
|
||||
};
|
||||
if t.range.is_empty() {
|
||||
if story.is_empty()
|
||||
&& let Some(run) = story.chars.first_mut()
|
||||
{
|
||||
set(&mut run.format);
|
||||
story.rev = story.rev.wrapping_add(1);
|
||||
}
|
||||
} else {
|
||||
story.format_chars(t.range.clone(), set);
|
||||
}
|
||||
}
|
||||
Ok(json!({"swatch": name}))
|
||||
});
|
||||
let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) };
|
||||
if text && texts.is_empty() {
|
||||
return Err(bad("object.color", "select the text to colour"));
|
||||
}
|
||||
let ids = super::targets(s, p)?;
|
||||
s.edit(|d, _| {
|
||||
// A caret: the colour is for the next typing (once the swatch exists).
|
||||
let caret = matches!(texts.as_slice(), [t] if t.range.is_empty());
|
||||
let r = s.edit(|d, _| {
|
||||
let name = color_swatch(d, color);
|
||||
color_items(d, &ids, &name, stroke);
|
||||
if !caret {
|
||||
let a = if stroke {
|
||||
CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() }
|
||||
} else {
|
||||
CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() }
|
||||
};
|
||||
super::text::apply_char_attrs(d, &texts, &a, &[]);
|
||||
}
|
||||
Ok(json!({"swatch": name}))
|
||||
})
|
||||
})?;
|
||||
if caret && let Some(name) = r.get("swatch").cloned() {
|
||||
let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) };
|
||||
super::text::format_chars(s, &attrs)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// A process swatch holding exactly `color`, else a new unnamed one (hidden from the Swatches
|
||||
@@ -1153,7 +1135,7 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let rename = str_param(p, "name").map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
let result = s.edit(|d, _| {
|
||||
let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?;
|
||||
if w.locked {
|
||||
return Err(bad("swatch.addToSwatches", "special swatches are already listed"));
|
||||
@@ -1180,8 +1162,84 @@ fn add_to_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rename_in(std::sync::Arc::make_mut(top), &name, &new);
|
||||
}
|
||||
}
|
||||
for st in d.stories.values_mut() {
|
||||
let mut copy = (**st).clone();
|
||||
if rename_in_story(&mut copy, &name, &new, 0)? {
|
||||
*st = std::sync::Arc::new(copy);
|
||||
}
|
||||
}
|
||||
Ok(json!({"name": new}))
|
||||
})
|
||||
})?;
|
||||
if let Some(new) = result.get("name").and_then(Value::as_str) {
|
||||
super::text::rename_typing_swatch(s, &name, new);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Point text colours (character fill, stroke, underline and strikethrough; paragraph shading,
|
||||
/// border and rules; table cells, borders and alternating fills) at a renamed swatch, in a story
|
||||
/// and the texts inside it (table cells, footnotes, endnotes). Returns whether anything changed.
|
||||
fn rename_in_story(st: &mut designcraft_doc::Story, from: &str, to: &str, depth: usize) -> Result<bool> {
|
||||
// Refuse the whole edit instead of leaving deeper references pointing at a removed name.
|
||||
if depth > 32 {
|
||||
return Err(bad("swatch.addToSwatches", "story nesting exceeds 32 levels"));
|
||||
}
|
||||
fn fix(s: &mut String, from: &str, to: &str) -> bool {
|
||||
let hit = s == from;
|
||||
if hit {
|
||||
*s = to.to_string();
|
||||
}
|
||||
hit
|
||||
}
|
||||
let mut n = 0;
|
||||
let mut f = |s: &mut String| n += usize::from(fix(s, from, to));
|
||||
for r in &mut st.chars {
|
||||
let o = &mut r.format.over;
|
||||
for c in [&mut o.fill, &mut o.stroke, &mut o.underline_color, &mut o.strikethrough_color].into_iter().flatten() {
|
||||
f(c);
|
||||
}
|
||||
}
|
||||
for pf in &mut st.paras {
|
||||
let a = &mut pf.para;
|
||||
for c in [&mut a.shading_color, &mut a.border_color].into_iter().flatten() {
|
||||
f(c);
|
||||
}
|
||||
for r in [&mut a.rule_above, &mut a.rule_below].into_iter().flatten() {
|
||||
f(&mut r.color);
|
||||
}
|
||||
}
|
||||
let mut nested = false;
|
||||
for t in st.tables.values_mut() {
|
||||
let t = std::sync::Arc::make_mut(t);
|
||||
let o = &mut t.options;
|
||||
f(&mut o.border.color);
|
||||
for b in o.borders.iter_mut().flatten() {
|
||||
f(&mut b.color);
|
||||
}
|
||||
for a in [&mut o.alt_rows, &mut o.alt_cols].into_iter().flatten() {
|
||||
f(&mut a.first_color);
|
||||
f(&mut a.next_color);
|
||||
}
|
||||
for k in [&mut o.row_strokes, &mut o.column_strokes].into_iter().flatten() {
|
||||
f(&mut k.first_stroke.color);
|
||||
f(&mut k.next_stroke.color);
|
||||
}
|
||||
for c in &mut t.cells {
|
||||
f(&mut c.fill);
|
||||
for k in &mut c.strokes {
|
||||
f(&mut k.color);
|
||||
}
|
||||
nested |= rename_in_story(&mut c.text, from, to, depth + 1)?;
|
||||
}
|
||||
}
|
||||
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
nested |= rename_in_story(&mut std::sync::Arc::make_mut(note).text, from, to, depth + 1)?;
|
||||
}
|
||||
let changed = n > 0 || nested;
|
||||
if changed {
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Point fills and strokes of an item (and its children) at a renamed swatch.
|
||||
@@ -1495,6 +1553,40 @@ mod color_tests {
|
||||
s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() {
|
||||
use std::sync::Arc;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap();
|
||||
s.execute("footnote.insert", &json!({"text": "note"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap();
|
||||
let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string();
|
||||
// The same unnamed colour on a cell, the cell's text and the footnote's text.
|
||||
s.edit(|d, _| {
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
let t = Arc::make_mut(st.tables.values_mut().next().unwrap());
|
||||
t.cells[0].fill = old.clone();
|
||||
t.cells[0].text.chars[0].format.over.fill = Some(old.clone());
|
||||
Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let st = d.story(sid).unwrap();
|
||||
assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand"));
|
||||
let cell = &st.tables.values().next().unwrap().cells[0];
|
||||
assert_eq!(cell.fill, "Sand");
|
||||
assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand"));
|
||||
assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand"));
|
||||
d.check().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1708,15 +1800,16 @@ mod sample_color_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_sample_or_nothing_to_colour_is_refused_without_an_edit() {
|
||||
fn nothing_to_sample_is_refused_without_an_edit() {
|
||||
let (mut s, _) = session_with_image();
|
||||
let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap();
|
||||
let fid = ItemId(f["id"].as_u64().unwrap());
|
||||
let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story;
|
||||
s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap();
|
||||
let sampled = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap();
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], sampled["swatch"]);
|
||||
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing");
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let caret = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap_err().to_string();
|
||||
assert!(caret.contains("select the text"), "{caret}");
|
||||
s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap();
|
||||
let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string();
|
||||
assert!(none.contains("no placed image"), "{none}");
|
||||
@@ -1751,3 +1844,7 @@ mod sample_color_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_color_tests.rs"]
|
||||
mod text_color_tests;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! whole table).
|
||||
|
||||
use designcraft_compose as compose;
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{
|
||||
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
|
||||
VerticalJustification,
|
||||
@@ -140,11 +141,9 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let g = target(s, p, "table.textCell")?;
|
||||
edit_table(s, &g, "table.textCell", |t| {
|
||||
for r in g.range.r0..=g.range.r1 {
|
||||
for c in g.range.c0..=g.range.c1 {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.graphic = None;
|
||||
}
|
||||
for (r, c) in t.selected_owners(g.range) {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.graphic = None;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
@@ -351,13 +350,17 @@ fn clamp_selection(d: &Document, sel: &mut Selection) {
|
||||
match story.and_then(|x| x.tables.get(&c.table)) {
|
||||
Some(tb) => {
|
||||
let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1));
|
||||
let len = tb.cell(r, col).map_or(0, |x| x.text.len());
|
||||
sel.text =
|
||||
Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t });
|
||||
let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str());
|
||||
sel.text = Some(TextSel {
|
||||
anchor: floor_char_boundary(text, t.anchor),
|
||||
focus: floor_char_boundary(text, t.focus),
|
||||
cell: Some(CellAddr { table: c.table, row: r, col }),
|
||||
..t
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let len = story.map_or(0, |x| x.len());
|
||||
sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t });
|
||||
let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor));
|
||||
sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -648,7 +651,6 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
|
||||
let stroke = p.get("stroke").cloned();
|
||||
edit_table(s, &g, "table.setCell", |t| {
|
||||
let owners = t.owners();
|
||||
let nc = t.ncols();
|
||||
let nr = t.nrows();
|
||||
let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
@@ -656,50 +658,45 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Local edits outrank previously imported edges, including explicit zero/None.
|
||||
let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 };
|
||||
let rg = g.range;
|
||||
for r in rg.r0..=rg.r1 {
|
||||
for c in rg.c0..=rg.c1 {
|
||||
if owners[r * nc + c] != (r, c) {
|
||||
continue;
|
||||
}
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
if let Some(f) = &fill {
|
||||
cell.fill = f.clone();
|
||||
}
|
||||
if let Some(ti) = tint {
|
||||
cell.fill_tint = ti;
|
||||
}
|
||||
if let Some(i) = insets {
|
||||
cell.insets = i;
|
||||
}
|
||||
if let Some(v) = vj {
|
||||
cell.vj = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
}
|
||||
if let Some(sv) = &stroke {
|
||||
let edges = str_param(sv, "edges").unwrap_or("all");
|
||||
let at_bottom = r.saturating_add(cell.row_span.max(1) as usize);
|
||||
let at_right = c.saturating_add(cell.col_span.max(1) as usize);
|
||||
let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }];
|
||||
for (i, on) in [
|
||||
("top", r == rg.r0),
|
||||
("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }),
|
||||
("bottom", at_bottom.saturating_sub(1) == rg.r1),
|
||||
("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer)))
|
||||
{
|
||||
if on {
|
||||
let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] };
|
||||
cell.strokes[i] = parse_stroke(sv, base.clone());
|
||||
cell.stroke_defined[i] = true;
|
||||
cell.stroke_priorities[i] = priority;
|
||||
cell.border_overrides[i] = true;
|
||||
}
|
||||
for (r, c) in t.selected_owners(rg) {
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
if let Some(f) = &fill {
|
||||
cell.fill = f.clone();
|
||||
}
|
||||
if let Some(ti) = tint {
|
||||
cell.fill_tint = ti;
|
||||
}
|
||||
if let Some(i) = insets {
|
||||
cell.insets = i;
|
||||
}
|
||||
if let Some(v) = vj {
|
||||
cell.vj = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
}
|
||||
if let Some(sv) = &stroke {
|
||||
let edges = str_param(sv, "edges").unwrap_or("all");
|
||||
let at_bottom = r.saturating_add(cell.row_span.max(1) as usize);
|
||||
let at_right = c.saturating_add(cell.col_span.max(1) as usize);
|
||||
let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }];
|
||||
for (i, on) in [
|
||||
("top", r == rg.r0),
|
||||
("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }),
|
||||
("bottom", at_bottom.saturating_sub(1) == rg.r1),
|
||||
("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer)))
|
||||
{
|
||||
if on {
|
||||
let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] };
|
||||
cell.strokes[i] = parse_stroke(sv, base.clone());
|
||||
cell.stroke_defined[i] = true;
|
||||
cell.stroke_priorities[i] = priority;
|
||||
cell.border_overrides[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -707,6 +704,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Keep both copies of unmerged shared boundaries consistent. Left/right name
|
||||
// physical sides, also in RTL tables.
|
||||
if let Some(sv) = &stroke {
|
||||
let owners = t.owners();
|
||||
let edges = str_param(sv, "edges").unwrap_or("all");
|
||||
let selected = |side: &str| edges == "all" || edges == "outer" || edges == side;
|
||||
if selected("top")
|
||||
@@ -931,8 +929,9 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("altCols") {
|
||||
o.alt_cols = parse_alt(v);
|
||||
}
|
||||
let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v as usize);
|
||||
// At most 100 of each, as in `table.insert`: the rows the table lacks are added below.
|
||||
let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize);
|
||||
let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize);
|
||||
if h.is_some() || f.is_some() {
|
||||
let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows()));
|
||||
// Add rows when there aren't enough for the requested header/footer plus one body row.
|
||||
@@ -1095,7 +1094,10 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cell = d
|
||||
.story(g.story)
|
||||
.and_then(|st| st.tables.get(&g.table))
|
||||
.and_then(|t| t.cell(g.range.r0, g.range.c0))
|
||||
.and_then(|t| {
|
||||
let (r, c) = t.owner(g.range.r0, g.range.c0);
|
||||
t.cell(r, c)
|
||||
})
|
||||
.ok_or_else(|| bad("style.cell.create", "no cell"))?;
|
||||
cs.fill = Some(cell.fill.clone());
|
||||
cs.fill_tint = Some(cell.fill_tint);
|
||||
@@ -1122,17 +1124,11 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cs = designcraft_doc::resolve_cell_style(styles, &name);
|
||||
let g = target(s, p, "style.cell.apply")?;
|
||||
edit_table(s, &g, "style.cell.apply", |t| {
|
||||
let owners = t.owners();
|
||||
let nc = t.ncols();
|
||||
let mut n = 0;
|
||||
for r in g.range.r0..=g.range.r1 {
|
||||
for c in g.range.c0..=g.range.c1 {
|
||||
if owners[r * nc + c] == (r, c)
|
||||
&& let Some(cell) = t.cell_mut(r, c)
|
||||
{
|
||||
cs.apply_to(cell);
|
||||
n += 1;
|
||||
}
|
||||
for (r, c) in t.selected_owners(g.range) {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cs.apply_to(cell);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"cells": n}))
|
||||
@@ -1353,7 +1349,7 @@ fn drop_cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& ts.story == sid
|
||||
&& ts.table == tid
|
||||
&& tid2 == tid
|
||||
&& ts.range.contains(fr, fc)
|
||||
&& s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok())
|
||||
{
|
||||
let (nr, nc) = {
|
||||
let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?;
|
||||
@@ -1680,6 +1676,19 @@ mod sort_tests {
|
||||
s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap();
|
||||
assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_options_limits_header_and_footer_rows() {
|
||||
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": 2, "cols": 2})).unwrap();
|
||||
// The limit `table.insert` has: the table used to grow by as many rows as were asked for.
|
||||
s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap();
|
||||
let t = table(&s);
|
||||
assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+575
-65
@@ -22,22 +22,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
release
|
||||
),
|
||||
cmd!(noundo "text.extendTo", "Extend Text Selection", [], None, "{frame, point}", has_doc, |s, p| place(s, p, true)),
|
||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point}", has_doc, |s, p| {
|
||||
place(s, p, false)?;
|
||||
let st = s.doc_mut()?;
|
||||
let Some(t) = st.selection.text else { return ok() };
|
||||
let text = &st.doc.text_story(t.story, t.cell).map(|x| x.text.clone()).unwrap_or_default();
|
||||
let (a, b) = word_bounds(text, t.focus);
|
||||
st.selection.text = Some(TextSel { anchor: a, focus: b, ..t });
|
||||
ok()
|
||||
cmd!(noundo "text.selectWord", "Select Word", [], None, "{frame, point} — the word (or run of spaces) under the point", has_doc, |s, p| {
|
||||
select_at(s, p, "word")
|
||||
}),
|
||||
cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point (triple click)", has_doc, |s, p| {
|
||||
cmd!(
|
||||
noundo "text.selectAt",
|
||||
"Select Text Unit",
|
||||
[],
|
||||
None,
|
||||
"{frame, point, unit: word|line|paragraph|story} — what a double, triple, quadruple or quintuple click selects: the word (or run of spaces) under the point, its line, its paragraph with the paragraph return, or the whole story",
|
||||
has_doc,
|
||||
|s, p| select_at(s, p, str_param(p, "unit").unwrap_or("word"))
|
||||
),
|
||||
cmd!(noundo "text.selectSentence", "Select Sentence", [], None, "{frame, point} — the sentence under the point", has_doc, |s, p| {
|
||||
select_around(s, p, "text.selectSentence", sentence_bounds)
|
||||
}),
|
||||
cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point (quadruple click)", has_doc, |s, p| {
|
||||
cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{frame, point} — the paragraph under the point, without its paragraph return", has_doc, |s, p| {
|
||||
select_around(s, p, "text.selectParagraph", para_bounds)
|
||||
}),
|
||||
cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included (quintuple click)", has_doc, |s, p| {
|
||||
cmd!(noundo "text.selectStory", "Select Story", [], None, "{frame, point} — all the text of the story under the point, threaded frames included", has_doc, |s, p| {
|
||||
select_around(s, p, "text.selectStory", |t, _| (0, t.len()))
|
||||
}),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{story, anchor, focus} (UTF-8 byte offsets into the story text, as find.find reports them: á or — counts 2 or 3)", has_doc, |s, p| {
|
||||
@@ -48,6 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a));
|
||||
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None });
|
||||
st.revision += 1;
|
||||
s.typing_format = None;
|
||||
ok()
|
||||
}),
|
||||
cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert),
|
||||
@@ -144,7 +148,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Paragraph Formatting",
|
||||
[],
|
||||
None,
|
||||
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
|
||||
"{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}",
|
||||
has_text_or_frames,
|
||||
|s, p| format_paras(s, p.get("attrs").unwrap_or(p))
|
||||
),
|
||||
@@ -161,8 +165,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s,
|
||||
&json!({"align": "leftJustified"})
|
||||
)),
|
||||
cmd!("type.bold", "Bold", [], Some("Cmd+Shift+B"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Bold"}))),
|
||||
cmd!("type.italic", "Italic", [], Some("Cmd+Shift+I"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"fontStyle": "Italic"}))),
|
||||
cmd!(
|
||||
"type.bold",
|
||||
"Bold",
|
||||
[],
|
||||
Some("Cmd+Shift+B"),
|
||||
"{on?: bool} — the family's bold style, keeping italic (style linking); toggles when `on` is left out",
|
||||
has_text_or_frames,
|
||||
|s, p| link_style(s, p, true)
|
||||
),
|
||||
cmd!(
|
||||
"type.italic",
|
||||
"Italic",
|
||||
[],
|
||||
Some("Cmd+Shift+I"),
|
||||
"{on?: bool} — the family's italic style, keeping the weight (style linking); toggles when `on` is left out",
|
||||
has_text_or_frames,
|
||||
|s, p| link_style(s, p, false)
|
||||
),
|
||||
cmd!(
|
||||
"type.swapFillStroke",
|
||||
"Swap Text Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{} — swap each selected character's resolved fill/stroke and tints; at a caret, swap the next typing's colors",
|
||||
has_text_or_frames,
|
||||
swap_fill_stroke
|
||||
),
|
||||
cmd!("type.underline", "Underline", [], Some("Cmd+Shift+U"), "{}", has_text_or_frames, |s, _| format_chars(s, &json!({"underline": true}))),
|
||||
cmd!("type.allCaps", "All Caps", [], Some("Cmd+Shift+K"), "{}", has_text_or_frames, |s, _| format_chars(
|
||||
s,
|
||||
@@ -280,6 +309,17 @@ pub(crate) fn story_of(s: &Session, p: &Value) -> Option<StoryId> {
|
||||
|
||||
/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell).
|
||||
fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option<CellAddr>)> {
|
||||
hit_byte_with(s, frame, pt, compose::hit)
|
||||
}
|
||||
|
||||
/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`]
|
||||
/// (the character under the point).
|
||||
fn hit_byte_with(
|
||||
s: &Session,
|
||||
frame: ItemId,
|
||||
pt: Point,
|
||||
hit: fn(&compose::ComposedStory, usize, Point) -> Option<usize>,
|
||||
) -> Option<(StoryId, usize, Option<CellAddr>)> {
|
||||
let st = s.active()?;
|
||||
let loc = st.doc.find(frame)?;
|
||||
let it = st.doc.item_at(&loc)?;
|
||||
@@ -288,18 +328,94 @@ fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Op
|
||||
let inner = xf.inverse() * pt;
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
let fi = cs.frames.iter().position(|f| f.frame == frame)?;
|
||||
// Type on a path: the click lands on the line where it is along the path.
|
||||
// Map path-text hits along the curve before choosing the character unit.
|
||||
let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner));
|
||||
if let Some((table, row, col, b)) = compose::hit_cell(&cs, fi, inner) {
|
||||
if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) {
|
||||
return Some((sid, b, Some(CellAddr { table, row, col })));
|
||||
}
|
||||
if let Some((id, b)) = compose::hit_note(&cs, fi, inner) {
|
||||
if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) {
|
||||
return Some((sid, b, Some(CellAddr::footnote(id))));
|
||||
}
|
||||
let b = compose::hit(&cs, fi, inner).unwrap_or(0);
|
||||
let b = hit(&cs, fi, inner).unwrap_or(0);
|
||||
Some((sid, b, None))
|
||||
}
|
||||
|
||||
/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks).
|
||||
fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result<Value> {
|
||||
const ID: &str = "text.selectAt";
|
||||
let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?;
|
||||
let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?;
|
||||
let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?;
|
||||
let st = s.doc()?;
|
||||
let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
let text = story.text.as_str();
|
||||
let b = floor_char_boundary(text, b);
|
||||
let (a, e) = match unit {
|
||||
"word" => word_or_spaces_at(text, b),
|
||||
"line" => {
|
||||
let cs = s.cache.get(&st.doc, sid, None);
|
||||
line_at(&text_composition(cs, cell), b).unwrap_or((b, b))
|
||||
}
|
||||
// The paragraph with its return (the last paragraph has none).
|
||||
"paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))),
|
||||
"story" => (0, text.len()),
|
||||
other => return Err(bad(ID, format!("unknown unit `{other}` (word, line, paragraph or story)"))),
|
||||
};
|
||||
s.text_drag = false;
|
||||
s.typing_format = None;
|
||||
let st = s.doc_mut()?;
|
||||
st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell });
|
||||
st.revision += 1;
|
||||
Ok(json!({"story": sid.0, "anchor": a, "focus": e}))
|
||||
}
|
||||
|
||||
/// The word at byte `i`; on spaces the run of spaces; on anything else that one character
|
||||
/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split:
|
||||
/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base.
|
||||
fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
let i = floor_char_boundary(s, i.min(s.len()));
|
||||
// Clusters never span a paragraph return, so the paragraph is enough.
|
||||
let ps = s[..i].rfind('\n').map_or(0, |k| k + 1);
|
||||
let pe = s[i..].find('\n').map_or(s.len(), |k| i + k);
|
||||
let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect();
|
||||
let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) };
|
||||
let kind = |c: &str| match c.chars().next() {
|
||||
Some(c) if is_word(c) => 1,
|
||||
Some(c) if c.is_whitespace() => 2,
|
||||
_ => 0,
|
||||
};
|
||||
let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len()));
|
||||
let kd = g.get(k).map_or(0, |(_, c)| kind(c));
|
||||
if kd == 0 {
|
||||
return span(k, k);
|
||||
}
|
||||
let mut a = k;
|
||||
while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||||
a -= 1;
|
||||
}
|
||||
let mut e = k;
|
||||
while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) {
|
||||
e += 1;
|
||||
}
|
||||
span(a, e)
|
||||
}
|
||||
|
||||
/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's.
|
||||
fn text_composition(cs: std::sync::Arc<compose::ComposedStory>, cell: Option<CellAddr>) -> std::sync::Arc<compose::ComposedStory> {
|
||||
match cell {
|
||||
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
|
||||
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
|
||||
None => cs,
|
||||
}
|
||||
}
|
||||
|
||||
/// The composed line holding the character at byte `pos`.
|
||||
fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
|
||||
let lines = || cs.frames.iter().flat_map(|f| f.lines.iter());
|
||||
lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos))
|
||||
}
|
||||
|
||||
fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
||||
let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?;
|
||||
let pt = point_param(p, "point").unwrap_or(Point::ZERO);
|
||||
@@ -318,6 +434,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result<Value> {
|
||||
} else if s.text_drag {
|
||||
return Ok(json!({"story": sid.0, "dragging": true}));
|
||||
}
|
||||
s.typing_format = None;
|
||||
let st = s.doc_mut()?;
|
||||
// Dragging from one cell into another selects cells.
|
||||
if extend
|
||||
@@ -385,7 +502,8 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text };
|
||||
let tracking = s.doc()?.doc.settings.track_changes;
|
||||
let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone());
|
||||
s.edit(|d, sel| {
|
||||
let typing = typing_format(s).cloned();
|
||||
let result = s.edit(|d, sel| {
|
||||
let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?;
|
||||
let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let r = t.range();
|
||||
@@ -445,6 +563,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Track Changes: the replaced text is marked deleted, the typing inserted after it.
|
||||
let at = super::changes::mark_deleted(st, r.clone());
|
||||
let mut fmt = st.char_format_at(at).clone();
|
||||
if let Some(f) = &typing {
|
||||
apply_typing_format(&mut fmt, f);
|
||||
}
|
||||
fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted);
|
||||
st.insert_with(at, &text, fmt);
|
||||
let pos = at + text.len() - trail;
|
||||
@@ -452,10 +573,19 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"pos": pos}));
|
||||
}
|
||||
st.replace(r.clone(), &text);
|
||||
if let Some(f) = &typing
|
||||
&& !text.is_empty()
|
||||
{
|
||||
st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f));
|
||||
}
|
||||
let pos = r.start + text.len() - trail;
|
||||
sel.text = Some(TextSel { anchor: pos, focus: pos, ..t });
|
||||
Ok(json!({"pos": pos}))
|
||||
})
|
||||
});
|
||||
if result.is_ok() {
|
||||
s.typing_format = None;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn smart_quotes(s: &Session, text: &str) -> String {
|
||||
@@ -632,12 +762,6 @@ pub fn prev_word(s: &str, i: usize) -> usize {
|
||||
}
|
||||
0
|
||||
}
|
||||
fn word_bounds(s: &str, i: usize) -> (usize, usize) {
|
||||
let a = s[..i.min(s.len())].char_indices().rev().take_while(|(_, c)| is_word(*c)).last().map(|(k, _)| k).unwrap_or(i);
|
||||
let b = s[i.min(s.len())..].char_indices().find(|(_, c)| !is_word(*c)).map(|(k, _)| i + k).unwrap_or(s.len());
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret
|
||||
/// placement, a press inside the current selection is no start of a text drag here: it is the
|
||||
/// third or later click of a row, which lands in the word or sentence just selected.
|
||||
@@ -732,13 +856,8 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?;
|
||||
let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let text = story.text.clone();
|
||||
let cs = s.cache.get(&st.doc, t.story, None);
|
||||
// In a cell, lines come from the cell's own composition.
|
||||
let cs = match t.cell {
|
||||
Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs),
|
||||
Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs),
|
||||
None => cs,
|
||||
};
|
||||
let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell);
|
||||
let pos = t.focus.min(text.len());
|
||||
// Vertical lines run top to bottom and follow each other right to left: Down and Up move along
|
||||
// the line (Right and Left of horizontal text), Left and Right to the next and previous line.
|
||||
@@ -779,6 +898,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"storyEnd" => text.len(),
|
||||
other => return Err(bad("text.move", format!("unknown dir `{other}`"))),
|
||||
};
|
||||
s.typing_format = None;
|
||||
let stm = s.doc_mut()?;
|
||||
stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } });
|
||||
stm.revision += 1;
|
||||
@@ -823,14 +943,9 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
|
||||
&& let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table))
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
let owners = t.owners();
|
||||
for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) {
|
||||
for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) {
|
||||
if owners[r * t.ncols() + c] == (r, c) {
|
||||
let len = t.cell(r, c).map_or(0, |x| x.text.len());
|
||||
v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len });
|
||||
}
|
||||
}
|
||||
for (r, c) in t.selected_owners(ts.range) {
|
||||
let len = t.cell(r, c).map_or(0, |x| x.text.len());
|
||||
v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -849,6 +964,23 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Character formatting chosen at a bare caret in text: applied to what is typed there next.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TypingFormat {
|
||||
doc: u64,
|
||||
at: TextSel,
|
||||
attrs: CharAttrs,
|
||||
/// Overrides removed (`null` in `type.char`).
|
||||
cleared: Vec<String>,
|
||||
}
|
||||
|
||||
/// The typing format for the active document's caret, if it was chosen at this caret.
|
||||
fn typing_format(s: &Session) -> Option<&TypingFormat> {
|
||||
let st = s.active()?;
|
||||
let t = st.selection.text?;
|
||||
s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range())
|
||||
}
|
||||
|
||||
fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some());
|
||||
let on = match p.get("on").and_then(Value::as_bool) {
|
||||
@@ -908,32 +1040,50 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
validate_char_fill(&s.doc()?.doc, &a, "type.char")?;
|
||||
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
|
||||
let targets = format_targets(s);
|
||||
// A caret in text: the format is for the next typing.
|
||||
if let [t] = targets.as_slice()
|
||||
&& t.range.is_empty()
|
||||
&& let Some(at) = s.doc()?.selection.text
|
||||
&& s.doc()?.doc.text_story(t.story, t.cell).is_some_and(|st| !st.is_empty())
|
||||
{
|
||||
let doc = s.doc()?.uid;
|
||||
let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] });
|
||||
f.attrs.merge(&a);
|
||||
f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k));
|
||||
f.cleared.extend(cleared);
|
||||
s.typing_format = Some(f);
|
||||
return ok();
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for t in &targets {
|
||||
let r = &t.range;
|
||||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
if r.is_empty() {
|
||||
// Caret: change the typing format (the empty run at the caret).
|
||||
let pos = r.start;
|
||||
if st.is_empty() {
|
||||
st.chars[0].format.over.merge(&a);
|
||||
st.rev += 1;
|
||||
} else {
|
||||
let _ = pos;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
st.format_chars(r.clone(), |f| {
|
||||
f.over.merge(&a);
|
||||
for k in &cleared {
|
||||
let _ = f.over.set_json(k, &Value::Null);
|
||||
}
|
||||
});
|
||||
}
|
||||
apply_char_attrs(d, &targets, &a, &cleared);
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// Character attributes `a` (and the overrides `cleared` removed) over each target's text.
|
||||
pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) {
|
||||
for t in targets {
|
||||
let r = &t.range;
|
||||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
if r.is_empty() {
|
||||
// An empty story: its one (empty) run is the format typing takes.
|
||||
if st.is_empty()
|
||||
&& let Some(run) = st.chars.first_mut()
|
||||
{
|
||||
run.format.over.merge(a);
|
||||
st.rev += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
st.format_chars(r.clone(), |f| {
|
||||
f.over.merge(a);
|
||||
for k in cleared {
|
||||
let _ = f.over.set_json(k, &Value::Null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
|
||||
let empty = serde_json::Map::new();
|
||||
let obj = attrs.as_object().unwrap_or(&empty);
|
||||
@@ -1056,14 +1206,178 @@ fn selection_attrs(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let pi = story.para_at(r.start);
|
||||
let pf = &story.paras[pi];
|
||||
let (pp, base) = st.doc.styles.resolve_para(pf);
|
||||
let cf = if r.is_empty() { story.char_format_at(r.start) } else { story.format_after(r.start) };
|
||||
let cp = st.doc.styles.resolve_char(&base, cf);
|
||||
// The selected text (its start, on one line), for previews such as the font menu's.
|
||||
let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?;
|
||||
let cp = st.doc.styles.resolve_char(&base, &cf);
|
||||
let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars());
|
||||
Ok(json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp,
|
||||
"paraOverrides": pf.para.count(), "charOverrides": cf.over.count(), "selectedText": selected.trim()}))
|
||||
}
|
||||
|
||||
/// The first target character, with any pending caret overrides. Keep character-style
|
||||
/// inheritance separate from paragraph defaults for style creation from the selection.
|
||||
pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result<designcraft_doc::CharFormat> {
|
||||
let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
|
||||
let mut format = if target.range.is_empty() { story.char_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone();
|
||||
if let Some(pending) = typing_format(s)
|
||||
&& target.story == pending.at.story
|
||||
&& target.cell == pending.at.cell
|
||||
&& target.range == pending.at.range()
|
||||
{
|
||||
apply_typing_format(&mut format, pending);
|
||||
}
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it.
|
||||
pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) {
|
||||
let Some(uid) = s.active().map(|st| st.uid) else { return };
|
||||
if let Some(pending) = &mut s.typing_format
|
||||
&& pending.doc == uid
|
||||
{
|
||||
for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if name == from {
|
||||
*name = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs {
|
||||
CharAttrs {
|
||||
fill: Some(cp.stroke.clone()),
|
||||
fill_tint: Some(cp.stroke_tint),
|
||||
stroke: Some(cp.fill.clone()),
|
||||
stroke_tint: Some(cp.fill_tint),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors.
|
||||
fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
const ID: &str = "type.swapFillStroke";
|
||||
let targets = format_targets(s);
|
||||
if let [target] = targets.as_slice()
|
||||
&& target.range.is_empty()
|
||||
&& s.doc()?.selection.text.is_some()
|
||||
{
|
||||
let st = s.doc()?;
|
||||
let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
|
||||
let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?;
|
||||
let (_, base) = st.doc.styles.resolve_para(pf);
|
||||
let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?);
|
||||
return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint}));
|
||||
}
|
||||
let mut edits = Vec::new();
|
||||
let d = &s.doc()?.doc;
|
||||
for target in targets {
|
||||
let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?;
|
||||
if story.is_empty() {
|
||||
let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?;
|
||||
let (_, base) = d.styles.resolve_para(pf);
|
||||
let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0)));
|
||||
validate_char_fill(d, &attrs, ID)?;
|
||||
edits.push((target, attrs));
|
||||
continue;
|
||||
}
|
||||
let mut runs = story.runs().peekable();
|
||||
for (pi, para) in story.para_ranges().into_iter().enumerate() {
|
||||
let start = para.start.max(target.range.start);
|
||||
let end = para.end.saturating_add(1).min(story.len()).min(target.range.end);
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?;
|
||||
let (_, base) = d.styles.resolve_para(pf);
|
||||
while let Some((range, format)) = runs.peek() {
|
||||
if range.end <= start {
|
||||
runs.next();
|
||||
continue;
|
||||
}
|
||||
if range.start >= end {
|
||||
break;
|
||||
}
|
||||
let attrs = swapped_colors(&d.styles.resolve_char(&base, format));
|
||||
validate_char_fill(d, &attrs, ID)?;
|
||||
edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs));
|
||||
if range.end > end {
|
||||
break;
|
||||
}
|
||||
runs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for (target, attrs) in &edits {
|
||||
apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]);
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) {
|
||||
cf.over.merge(&f.attrs);
|
||||
for k in &f.cleared {
|
||||
let _ = cf.over.set_json(k, &Value::Null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or
|
||||
/// off as asked, else the opposite of the first selected character.
|
||||
fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result<Value> {
|
||||
let id = if bold { "type.bold" } else { "type.italic" };
|
||||
let cur = selection_attrs(s, &json!({}))?;
|
||||
let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string();
|
||||
let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string();
|
||||
// The document's own fonts count too.
|
||||
let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope);
|
||||
let (weight, italic) = db.traits_of(&family, &style);
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic });
|
||||
let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) };
|
||||
let targets = format_targets(s);
|
||||
// A caret: the linked style of the format there, for the next typing.
|
||||
if let [t] = targets.as_slice()
|
||||
&& t.range.is_empty()
|
||||
{
|
||||
let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?;
|
||||
format_chars(s, &json!({"fontStyle": linked}))?;
|
||||
return Ok(json!({"on": on, "fontStyle": linked}));
|
||||
}
|
||||
let mut changed = 0;
|
||||
let r = s.edit(|d, _| {
|
||||
let styles = d.styles.clone();
|
||||
for t in &targets {
|
||||
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
|
||||
for (pi, pr) in st.para_ranges().into_iter().enumerate() {
|
||||
let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end));
|
||||
if a >= b {
|
||||
continue;
|
||||
}
|
||||
let Some(pf) = st.paras.get(pi) else { continue };
|
||||
let (_, pc) = styles.resolve_para(pf);
|
||||
st.format_chars(a..b, |f| {
|
||||
let cp = styles.resolve_char(&pc, f);
|
||||
if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic)
|
||||
&& linked != cp.font_style
|
||||
{
|
||||
f.over.font_style = Some(linked);
|
||||
changed += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// No undo step for nothing.
|
||||
if changed == 0 {
|
||||
return Err(bad(id, "nothing to change"));
|
||||
}
|
||||
ok()
|
||||
});
|
||||
r?;
|
||||
Ok(json!({"on": on, "changed": changed}))
|
||||
}
|
||||
|
||||
/// Our own filler text (not Adobe's).
|
||||
pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \
|
||||
et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \
|
||||
@@ -1363,6 +1677,202 @@ mod nested_style_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_click_tests {
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn moving_the_caret_drops_the_pending_typing_format() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap();
|
||||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||||
let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size;
|
||||
let pending = |s: &mut Session| {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
};
|
||||
// Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the
|
||||
// same caret is in the text's own format.
|
||||
let moves: Vec<Box<dyn Fn(&mut Session)>> = vec![
|
||||
Box::new(|s| {
|
||||
s.execute("text.move", &json!({"dir": "right"})).unwrap();
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
}),
|
||||
Box::new(|s| {
|
||||
s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
}),
|
||||
Box::new(|s| {
|
||||
s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
}),
|
||||
];
|
||||
for m in &moves {
|
||||
pending(&mut s);
|
||||
m(&mut s);
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(size_at(&s, 6), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// Without a move it applies.
|
||||
pending(&mut s);
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert_eq!(size_at(&s, 6), Some(30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_selection_keeps_grapheme_clusters_whole() {
|
||||
use super::word_or_spaces_at as word;
|
||||
// Decomposed "café": the combining acute belongs to the word, wherever the click lands.
|
||||
let s = "un cafe\u{301} noir";
|
||||
let cafe = (3, 3 + "cafe\u{301}".len());
|
||||
assert_eq!(word(s, 4), cafe);
|
||||
assert_eq!(word(s, 7), cafe, "on the combining mark");
|
||||
// Devanagari with a virama (U+094D) and a dependent vowel sign.
|
||||
let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x";
|
||||
let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len();
|
||||
assert_eq!(word(s, 0), (0, w));
|
||||
assert_eq!(word(s, 9), (0, w), "on the virama");
|
||||
// An emoji ZWJ sequence and a variation selector stay one character.
|
||||
let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}";
|
||||
let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len());
|
||||
assert_eq!(word(s, 2), e);
|
||||
assert_eq!(word(s, 6), e, "on the joiner");
|
||||
assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole");
|
||||
// Empty paragraphs select nothing.
|
||||
assert_eq!(word("a\n\nb", 2), (2, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_select_words_spaces_lines_paragraphs_and_the_story() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let text = "Hello brave world.\nSecond paragraph here.";
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap();
|
||||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||||
// Spread point over the right half of the glyph at byte `b`.
|
||||
let at = |s: &Session, b: usize| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
|
||||
let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap();
|
||||
json!([g.x + g.adv * 0.75, l.baseline - 3.0])
|
||||
};
|
||||
let mut select = |unit: &str, b: usize| {
|
||||
let p = at(&s, b);
|
||||
let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap();
|
||||
(r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize)
|
||||
};
|
||||
assert_eq!(select("word", 1), (0, 5), "inside a word");
|
||||
assert_eq!(select("word", 4), (0, 5), "its last letter");
|
||||
assert_eq!(select("word", 5), (5, 7), "the run of spaces after it");
|
||||
assert_eq!(select("word", 18), (18, 19), "punctuation alone");
|
||||
assert_eq!(select("line", 9), (0, 19));
|
||||
assert_eq!(select("paragraph", 9), (0, 20), "with its return");
|
||||
assert_eq!(select("paragraph", 22), (20, text.len()));
|
||||
assert_eq!(select("story", 9), (0, text.len()));
|
||||
// A second click inside the selected word selects again rather than starting a text drag.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap();
|
||||
let p = at(&s, 9);
|
||||
s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (0, 19));
|
||||
s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it");
|
||||
// The older word command agrees.
|
||||
let p = at(&s, 8);
|
||||
s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap();
|
||||
let t = s.doc().unwrap().selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (7, 12));
|
||||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err());
|
||||
assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod style_link_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn bold_and_italic_follow_the_family_and_toggle() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
let style_at = |s: &mut Session, b: usize| {
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap();
|
||||
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string()
|
||||
};
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap();
|
||||
s.execute("type.italic", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&mut s, 0), "Italic");
|
||||
// Bold over italic and plain text: each run takes its own linked style.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&mut s, 0), "Bold Italic");
|
||||
assert_eq!(style_at(&mut s, 7), "Bold");
|
||||
// Again: off, back to each run's regular weight.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap();
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
assert_eq!(style_at(&mut s, 0), "Italic");
|
||||
assert_eq!(style_at(&mut s, 7), "Regular");
|
||||
// Nothing to change: an error and no undo step.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert!(s.execute("type.bold", &json!({"on": false})).is_err());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
// At a caret the style is for what is typed next.
|
||||
let end = "plain words here".len();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap();
|
||||
s.execute("type.bold", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold");
|
||||
s.execute("text.insert", &json!({"text": " now"})).unwrap();
|
||||
assert_eq!(style_at(&mut s, end + 1), "Bold");
|
||||
assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged");
|
||||
// Moving the caret forgets it.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap();
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod text_color_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn unnamed_colours_go_to_text_when_asked() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap();
|
||||
let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap());
|
||||
s.execute("selection.set", &json!({"ids": [fid]})).unwrap();
|
||||
let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone();
|
||||
let before = fill_of_frame(&s);
|
||||
let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap();
|
||||
let red = r["swatch"].as_str().unwrap().to_string();
|
||||
assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill");
|
||||
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
|
||||
assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str()));
|
||||
// A text selection takes it without asking.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap();
|
||||
let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap();
|
||||
let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
|
||||
assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod drag_text_tests {
|
||||
use serde_json::json;
|
||||
@@ -2009,7 +2519,7 @@ mod story_query_contract_tests {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod multi_click_tests {
|
||||
mod sentence_selection_tests {
|
||||
use super::{para_bounds, sentence_bounds};
|
||||
|
||||
fn sentence(s: &str, at: &str) -> String {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Cross-command caret color and character-style regressions.
|
||||
use crate::Session;
|
||||
use designcraft_doc::StoryId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn setup(text: &str) -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [36,36,300,200], "content":"text", "text":text})).unwrap();
|
||||
let sid = f["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story":sid,"anchor":text.len()})).unwrap();
|
||||
(s, sid)
|
||||
}
|
||||
fn attrs(s: &mut Session) -> Value {
|
||||
s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"].clone()
|
||||
}
|
||||
#[test]
|
||||
fn integration_color_character_style_captures_pending_and_null() {
|
||||
let (mut s, sid) = setup("hello");
|
||||
s.execute("type.char", &json!({"size":30,"tracking":40,"fill":"[Paper]"})).unwrap();
|
||||
s.execute("style.character.create", &json!({"name":"Pending","fromSelection":true,"chars":{"tracking":null}})).unwrap();
|
||||
let a = &s.doc().unwrap().doc.styles.char_style("Pending").unwrap().chars;
|
||||
assert_eq!(a.size, Some(30.0));
|
||||
assert_eq!(a.fill.as_deref(), Some("[Paper]"));
|
||||
assert_eq!(a.tracking, None);
|
||||
assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().text, "hello");
|
||||
}
|
||||
#[test]
|
||||
fn integration_color_pending_follows_promoted_swatch() {
|
||||
for target in ["fill", "stroke"] {
|
||||
let (mut s, sid) = setup("hello");
|
||||
let old = s.execute("object.color", &json!({"color":"#123456","target":target})).unwrap()["swatch"].clone();
|
||||
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
|
||||
assert_eq!(attrs(&mut s)[target], "Brand");
|
||||
s.execute("text.insert", &json!({"text":"X"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
|
||||
let f = &st.format_after(5).over;
|
||||
assert_eq!(if target == "fill" { f.fill.as_deref() } else { f.stroke.as_deref() }, Some("Brand"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn integration_color_undo_discards_missing_pending_swatch() {
|
||||
let (mut s, sid) = setup("hello");
|
||||
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].as_str().unwrap().to_string();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.swatch(&old).is_none());
|
||||
assert_ne!(attrs(&mut s)["fill"], old);
|
||||
s.execute("text.insert", &json!({"text":"X"})).unwrap();
|
||||
assert_ne!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(5).over.fill.as_deref(), Some(old.as_str()));
|
||||
}
|
||||
#[test]
|
||||
fn integration_color_empty_story_uses_one_undo_step() {
|
||||
let (mut s, sid) = setup("");
|
||||
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.color", &json!({"color":"#123456"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
|
||||
let colored = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), colored);
|
||||
s.execute("text.insert", &json!({"text":"X"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().format_after(0).over.fill.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_color_swap_resolves_each_paragraph_and_keeps_unselected_text() {
|
||||
let (mut s, sid) = setup("ab\ncd");
|
||||
for (name, fill, stroke, ft, st, start, end) in
|
||||
[("First", "[Paper]", "[Black]", 0.2, 0.8, 0, 2), ("Second", "[Black]", "[Paper]", 0.4, 0.6, 3, 5)]
|
||||
{
|
||||
s.execute("style.paragraph.create", &json!({"name":name,"chars":{"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st}})).unwrap();
|
||||
s.execute("text.select", &json!({"story":sid,"anchor":start,"focus":end})).unwrap();
|
||||
s.execute("style.paragraph.apply", &json!({"name":name})).unwrap();
|
||||
}
|
||||
s.execute("text.select", &json!({"story":sid,"anchor":1,"focus":4})).unwrap();
|
||||
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("type.swapFillStroke", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1);
|
||||
for (pos, fill, stroke, ft, st) in [
|
||||
(0, "[Paper]", "[Black]", 0.2, 0.8),
|
||||
(1, "[Black]", "[Paper]", 0.8, 0.2),
|
||||
(2, "[Black]", "[Paper]", 0.8, 0.2),
|
||||
(3, "[Paper]", "[Black]", 0.6, 0.4),
|
||||
(4, "[Black]", "[Paper]", 0.4, 0.6),
|
||||
] {
|
||||
s.execute("text.select", &json!({"story":sid,"anchor":pos,"focus":pos+1})).unwrap();
|
||||
let a = attrs(&mut s);
|
||||
assert_eq!(a["fill"], fill);
|
||||
assert_eq!(a["stroke"], stroke);
|
||||
assert!((a["fillTint"].as_f64().unwrap() - ft).abs() < 1e-6);
|
||||
assert!((a["strokeTint"].as_f64().unwrap() - st).abs() < 1e-6);
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_color_swap_caret_applies_only_to_next_typing() {
|
||||
let (mut s, sid) = setup("hello");
|
||||
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"[Black]","fillTint":0.2,"strokeTint":0.8})).unwrap();
|
||||
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
|
||||
s.execute("type.swapFillStroke", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
|
||||
let a = attrs(&mut s);
|
||||
assert_eq!(a["fill"], "[Black]");
|
||||
assert_eq!(a["stroke"], "[Paper]");
|
||||
s.execute("text.insert", &json!({"text":"X"})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(StoryId(sid)).unwrap();
|
||||
assert_eq!(st.text, "helloX");
|
||||
assert_eq!(st.format_after(0).over.fill, None);
|
||||
assert_eq!(st.format_after(5).over.stroke.as_deref(), Some("[Paper]"));
|
||||
assert_eq!(st.format_after(5).over.fill_tint, Some(0.8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_color_swap_selected_empty_cells_keeps_each_format() {
|
||||
let (mut s, sid) = setup("hello");
|
||||
let result = s.execute("table.insert", &json!({"rows":1,"cols":2})).unwrap();
|
||||
let tid = result["table"].as_u64().unwrap();
|
||||
s.edit(|d, _| {
|
||||
let table = std::sync::Arc::make_mut(d.story_mut(StoryId(sid)).unwrap().tables.get_mut(&tid).unwrap());
|
||||
for (c, fill, stroke) in [(0, "[Paper]", "[Black]"), (1, "[Black]", "[Paper]")] {
|
||||
let attrs = &mut table.cell_mut(0, c).unwrap().text.chars[0].format.over;
|
||||
attrs.fill = Some(fill.into());
|
||||
attrs.stroke = Some(stroke.into());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("table.select", &json!({"story":sid,"table":tid,"what":"table"})).unwrap();
|
||||
let before = serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap();
|
||||
s.execute("type.swapFillStroke", &json!({})).unwrap();
|
||||
let table = &s.doc().unwrap().doc.story(StoryId(sid)).unwrap().tables[&tid];
|
||||
assert_eq!(table.cell(0, 0).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Black]"));
|
||||
assert_eq!(table.cell(0, 1).unwrap().text.chars[0].format.over.fill.as_deref(), Some("[Paper]"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap().doc.as_ref()).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_color_failed_promotion_preserves_pending_and_history() {
|
||||
let (mut s, _) = setup("hello");
|
||||
s.execute("swatch.create", &json!({"name":"Brand","color":"#654321"})).unwrap();
|
||||
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let pending = attrs(&mut s);
|
||||
assert!(s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).is_err());
|
||||
assert_eq!(attrs(&mut s), pending);
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_color_promote_undo_redo_never_reuses_a_missing_pending_name() {
|
||||
let (mut s, _) = setup("hello");
|
||||
let old = s.execute("object.color", &json!({"color":"#123456"})).unwrap()["swatch"].clone();
|
||||
s.execute("swatch.addToSwatches", &json!({"swatch":old,"name":"Brand"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
|
||||
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let fill = attrs(&mut s)["fill"].as_str().unwrap().to_string();
|
||||
assert!(s.doc().unwrap().doc.swatch(&fill).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_color_swap_validates_new_fill_before_any_edit() {
|
||||
let (mut s, sid) = setup("hello");
|
||||
s.execute("text.select", &json!({"story":sid,"anchor":0,"focus":5})).unwrap();
|
||||
s.execute("type.char", &json!({"fill":"[Paper]","stroke":"Missing Ink"})).unwrap();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let journal = s.journal.clone();
|
||||
let error = s.execute("type.swapFillStroke", &json!({})).unwrap_err().to_string();
|
||||
assert!(error.contains("Missing Ink"), "{error}");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before.doc));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before.history.undo.len());
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Threading text frames: thread a frame after another (their stories merge), break a thread,
|
||||
//! take a frame out of its thread. The Selection tool's loaded text cursor runs these.
|
||||
|
||||
use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Pages Shift-click autoflow may add before it gives up.
|
||||
const AUTOFLOW_MAX_PAGES: usize = 2000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"text.thread",
|
||||
"Thread Text Frames",
|
||||
[],
|
||||
None,
|
||||
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
|
||||
has_doc,
|
||||
thread
|
||||
),
|
||||
cmd!(
|
||||
"text.unthread",
|
||||
"Break Thread",
|
||||
[],
|
||||
None,
|
||||
"{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it",
|
||||
has_doc,
|
||||
unthread
|
||||
),
|
||||
cmd!(
|
||||
"text.removeFromThread",
|
||||
"Remove Frame from Thread",
|
||||
[],
|
||||
None,
|
||||
"{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty",
|
||||
has_doc,
|
||||
remove_from_thread
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `frame` param, or the first selected text frame.
|
||||
fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
|
||||
let st = s.doc()?;
|
||||
let id = id_param(p, "frame")
|
||||
.or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame())))
|
||||
.ok_or_else(|| bad(cmd, "no text frame given or selected"))?;
|
||||
if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) {
|
||||
return Err(bad(cmd, format!("{id} is not a text frame")));
|
||||
}
|
||||
check_editable(&st.doc, id, cmd)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
|
||||
let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
||||
if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) {
|
||||
return Err(bad(cmd, format!("{id} is locked or on a locked layer")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A rect param made safe for a frame: finite, ordered, at least half a point each way.
|
||||
fn frame_rect(p: &Value) -> Result<Option<Rect>> {
|
||||
let Some(r) = rect_param(p, "rect") else { return Ok(None) };
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return Err(bad("text.thread", "rect must be finite"));
|
||||
}
|
||||
let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1));
|
||||
let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1));
|
||||
Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5))))
|
||||
}
|
||||
|
||||
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?);
|
||||
let sr = spread_param(p, "spread")?;
|
||||
let autoflow = bool_or(p, "autoflow", false);
|
||||
let layer = s.doc()?.active_layer;
|
||||
s.edit(|d, sel| {
|
||||
let mut new_frame = || -> Result<ItemId> {
|
||||
let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?;
|
||||
if d.layer(layer).is_none_or(|l| l.locked || !l.visible) {
|
||||
return Err(bad("text.thread", "the active layer is locked or hidden"));
|
||||
}
|
||||
let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
|
||||
Ok(d.add_text_frame(sr, r, layer, "", para)?.0)
|
||||
};
|
||||
let (from, to, joined) = match (from, to) {
|
||||
(Some(f), Some(t)) if rect.is_none() => (f, t, t),
|
||||
(Some(f), None) => {
|
||||
let t = new_frame()?;
|
||||
(f, t, t)
|
||||
}
|
||||
(None, Some(t)) => {
|
||||
let f = new_frame()?;
|
||||
(f, t, f)
|
||||
}
|
||||
_ => return Err(bad("text.thread", "give two of from, to and rect")),
|
||||
};
|
||||
d.check_thread(from, to)?;
|
||||
d.thread_keeping_story(from, to)?;
|
||||
let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?;
|
||||
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread")? } else { 0 };
|
||||
*sel = Selection::items(vec![joined]);
|
||||
Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages}))
|
||||
})
|
||||
}
|
||||
|
||||
fn overset(d: &Document, sid: StoryId) -> bool {
|
||||
designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some()
|
||||
}
|
||||
|
||||
fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "text.unthread";
|
||||
let frame = frame_param(s, p, CMD)?;
|
||||
let d0 = &s.doc()?.doc;
|
||||
let at = match str_param(p, "side").unwrap_or("after") {
|
||||
"after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?,
|
||||
"before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?,
|
||||
other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))),
|
||||
};
|
||||
check_editable(d0, at, CMD)?;
|
||||
if let Some(next) = d0.next_frame(at) {
|
||||
check_editable(d0, next, CMD)?;
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
d.unthread_after(at)?;
|
||||
let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?;
|
||||
Ok(json!({"story": sid.0, "overset": overset(d, sid)}))
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_from_thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let frame = frame_param(s, p, "text.removeFromThread")?;
|
||||
let d = &s.doc()?.doc;
|
||||
for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() {
|
||||
check_editable(d, neighbour, "text.removeFromThread")?;
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
d.remove_from_thread(frame)?;
|
||||
Ok(json!({}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) {
|
||||
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap();
|
||||
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn story(s: &mut Session, frame: u64) -> serde_json::Value {
|
||||
s.execute("story.get", &json!({"frame": frame})).unwrap()
|
||||
}
|
||||
|
||||
fn frames(s: &mut Session, frame: u64) -> Vec<u64> {
|
||||
story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
const LONG: &str = "Threaded text keeps flowing from one frame into the next one. ";
|
||||
|
||||
#[test]
|
||||
fn thread_into_an_empty_frame_and_undo() {
|
||||
let mut s = session();
|
||||
let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10));
|
||||
let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset");
|
||||
let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
||||
assert_eq!(r["story"].as_u64(), Some(sid));
|
||||
assert_eq!(frames(&mut s, a), [a, b]);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
|
||||
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]);
|
||||
// The text now reaches the second frame.
|
||||
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
|
||||
assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame");
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a]);
|
||||
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_into_a_text_frame_merges_its_story() {
|
||||
let mut s = session();
|
||||
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First");
|
||||
let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second");
|
||||
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
||||
let st = story(&mut s, b);
|
||||
assert_eq!(st["id"].as_u64(), Some(sa));
|
||||
assert_eq!(st["text"], "First\nSecond");
|
||||
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none());
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_into_the_middle_of_a_thread() {
|
||||
let mut s = session();
|
||||
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
|
||||
let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], "");
|
||||
s.execute("text.thread", &json!({"from": a, "to": c})).unwrap();
|
||||
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
|
||||
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a, b, c]);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_threads_are_errors_and_change_nothing() {
|
||||
let mut s = session();
|
||||
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a");
|
||||
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b");
|
||||
let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x");
|
||||
let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y");
|
||||
let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap();
|
||||
let group = {
|
||||
let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone();
|
||||
let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone();
|
||||
s.execute("selection.set", &json!({"ids": [p, q]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.doc().unwrap().selection.items[0].0
|
||||
};
|
||||
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
||||
s.execute("text.thread", &json!({"from": x, "to": y})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
for p in [
|
||||
json!({"from": a, "to": a}),
|
||||
json!({"from": a, "to": g}),
|
||||
json!({"from": g, "to": a}),
|
||||
json!({"from": a, "to": group}),
|
||||
// Earlier in the same thread.
|
||||
json!({"from": b, "to": a}),
|
||||
// Text already flows into y from x.
|
||||
json!({"from": a, "to": y}),
|
||||
json!({"from": a, "to": 999_999}),
|
||||
json!({"from": a}),
|
||||
json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}),
|
||||
json!({"rect": [0, 0, 10, 10]}),
|
||||
json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}),
|
||||
json!({"from": "a", "to": [1]}),
|
||||
json!([1, 2]),
|
||||
] {
|
||||
assert!(s.execute("text.thread", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(*s.doc().unwrap().doc, *doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_frames_and_layers_refuse_threads() {
|
||||
let mut s = session();
|
||||
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
|
||||
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
|
||||
let layer = s.doc().unwrap().active_layer.0;
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.lock", &json!({"ids": [b]})).unwrap();
|
||||
assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame");
|
||||
assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] {
|
||||
let mut p = json!({"id": layer});
|
||||
p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone());
|
||||
s.execute("layer.set", &p).unwrap();
|
||||
let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) };
|
||||
assert!(s.execute("text.thread", &t).is_err(), "{p} {t}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(frames(&mut s, a), [a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_a_new_frame_after_or_in_front() {
|
||||
let mut s = session();
|
||||
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
|
||||
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap();
|
||||
let n = r["to"].as_u64().unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a, n]);
|
||||
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]);
|
||||
let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds();
|
||||
assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0));
|
||||
// In front of the first frame: the story keeps its id and text.
|
||||
let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap();
|
||||
let m = r["from"].as_u64().unwrap();
|
||||
assert_eq!(r["story"].as_u64(), Some(sa));
|
||||
assert_eq!(frames(&mut s, a), [m, a, n]);
|
||||
assert_eq!(story(&mut s, a)["text"], LONG.repeat(8));
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a, n]);
|
||||
}
|
||||
|
||||
use designcraft_geom::Rect;
|
||||
|
||||
#[test]
|
||||
fn thread_with_autoflow_adds_pages_until_the_text_fits() {
|
||||
let mut s = session();
|
||||
let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300));
|
||||
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap();
|
||||
assert_eq!(r["overset"], false);
|
||||
assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}");
|
||||
assert!(frames(&mut s, a).len() >= 3);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
// One undo removes the frames and the pages.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a]);
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autoflow_from_a_parent_frame_is_an_error() {
|
||||
let mut s = session();
|
||||
let parent = json!({"kind": "parent", "index": 0});
|
||||
let r = s.execute(
|
||||
"frame.create",
|
||||
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}),
|
||||
);
|
||||
let a = r.unwrap()["id"].as_u64().unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("text.thread"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(*s.doc().unwrap().doc, *doc);
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unthread_after_and_before() {
|
||||
let mut s = session();
|
||||
let text = LONG.repeat(12);
|
||||
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
|
||||
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
|
||||
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
|
||||
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
||||
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
|
||||
// Break after the first frame: b and c stay threaded to each other, empty.
|
||||
let r = s.execute("text.unthread", &json!({"frame": a})).unwrap();
|
||||
assert_eq!(r["story"].as_u64(), Some(sa));
|
||||
assert_eq!(r["overset"], true);
|
||||
assert_eq!(frames(&mut s, a), [a]);
|
||||
assert_eq!(story(&mut s, a)["text"], text);
|
||||
assert_eq!(frames(&mut s, b), [b, c]);
|
||||
assert_eq!(story(&mut s, b)["text"], "");
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a, b, c]);
|
||||
// Break before the last frame, with the frame selected.
|
||||
s.execute("selection.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("text.unthread", &json!({"side": "before"})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a, b]);
|
||||
assert_eq!(frames(&mut s, c), [c]);
|
||||
// Nothing to break.
|
||||
assert!(s.execute("text.unthread", &json!({"frame": b})).is_err());
|
||||
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err());
|
||||
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err());
|
||||
assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() {
|
||||
let mut s = session();
|
||||
let text = LONG.repeat(12);
|
||||
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
|
||||
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
|
||||
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
|
||||
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
||||
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
|
||||
s.execute("text.removeFromThread", &json!({"frame": b})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a, c]);
|
||||
assert_eq!(story(&mut s, a)["text"], text);
|
||||
assert_eq!(story(&mut s, b)["text"], "");
|
||||
assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(frames(&mut s, a), [a, c]);
|
||||
assert_eq!(story(&mut s, a)["text"], text);
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -214,4 +214,23 @@ mod tests {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_reference_into_a_story_with_a_running_header_composes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap();
|
||||
let head = s.execute("frame.create", &json!({"rect": [72, 72, 400, 120], "content": "text", "text": "Chapter"})).unwrap()["story"].clone();
|
||||
let body = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "See"})).unwrap()["story"].clone();
|
||||
s.execute("text.select", &json!({"story": head, "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("variables.insert", &json!({"name": "Running Header"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": body, "anchor": 3, "focus": 3})).unwrap();
|
||||
s.execute("xref.insert", &json!({"story": head, "para": 0})).unwrap();
|
||||
// The cross-reference index and the running-header index used to build each other without end.
|
||||
assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Chapter\u{201D} on page 1");
|
||||
// The header shows the body's paragraph in front of its own text.
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let cs = s.cache.get(&d, StoryId(head.as_u64().unwrap()), None);
|
||||
let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum();
|
||||
assert_eq!(glyphs, "SeeChapter".len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +276,8 @@ pub struct Session {
|
||||
pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool),
|
||||
/// Drag and drop text editing: the press landed in the selected text.
|
||||
pub(crate) text_drag: bool,
|
||||
/// Character formatting chosen at a bare caret, for the text typed there next.
|
||||
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
|
||||
/// The open Object Library (File › New / Open Library).
|
||||
pub library: Option<cmd::library::Library>,
|
||||
/// The open book (File › Open Book).
|
||||
@@ -307,6 +309,7 @@ impl Session {
|
||||
loaded: None,
|
||||
transforms: Default::default(),
|
||||
text_drag: false,
|
||||
typing_format: None,
|
||||
library: None,
|
||||
conveyor: Vec::new(),
|
||||
book: None,
|
||||
@@ -475,7 +478,7 @@ impl Session {
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
if overset {
|
||||
let _ = cmd::place_text_autoflow(&mut d, sid, 500);
|
||||
let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow");
|
||||
} else {
|
||||
// Drop empty trailing frames on the last pages (one frame, nothing else there).
|
||||
while let Some(story) = d.story(sid) {
|
||||
|
||||
@@ -569,3 +569,103 @@ fn commands_take_non_object_params_without_panicking() {
|
||||
assert!(r.is_ok(), "{p}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the text from a frame's out port with the Selection tool and click on the pasteboard:
|
||||
/// a new frame is threaded there and the overset text flows into it.
|
||||
#[test]
|
||||
fn threading_with_the_loaded_text_cursor_on_the_sample() {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let (sid, first) = d.stories.values().find(|st| st.frames.len() >= 2).map(|st| (st.id, st.frames[0])).expect("a threaded story");
|
||||
// Break the thread so the first frame is overset, and select it.
|
||||
s.execute("text.unthread", &json!({"frame": first.0})).unwrap();
|
||||
assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).is_overset());
|
||||
s.execute("selection.set", &json!({"ids": [first.0]})).unwrap();
|
||||
s.set_tool("selection");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let layout = s.layout();
|
||||
let port = designcraft_tools::thread::port_center(&s.doc().unwrap().doc, &layout, first, true, v.zoom).unwrap();
|
||||
let ev = |k, p: designcraft_geom::Point| PointerEvent::new(k, p.x, p.y);
|
||||
s.pointer(&ev(PointerKind::Down, port), v).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, port), v).unwrap();
|
||||
assert_eq!(s.cursor(port + designcraft_geom::Vec2::new(-2000.0, 0.0), Default::default(), v), designcraft_tools::Cursor::LoadedText);
|
||||
// On the pasteboard left of the first spread.
|
||||
let b = layout.slots[0].bounds;
|
||||
let at = designcraft_geom::Point::new(b.x0 - 300.0, b.y0 + 100.0);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.pointer(&ev(PointerKind::Down, at), v).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, at), v).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
|
||||
let frames = st.doc.story(sid).unwrap().frames.clone();
|
||||
assert_eq!(frames.len(), 2);
|
||||
let new = frames[1];
|
||||
assert_eq!(st.selection.items, [new]);
|
||||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
assert!(cs.frames.iter().any(|f| f.frame == new && !f.range.is_empty()), "the text flows into the new frame");
|
||||
let nb = s.doc().unwrap().doc.item(new).unwrap().bounds();
|
||||
assert!((nb.y0 - layout.to_spread(designcraft_doc::SpreadRef::Doc(0), at).y).abs() < 1e-6, "{nb:?}");
|
||||
}
|
||||
|
||||
/// Drag a selection handle of item 0 on spread 0 from the edge point `at` by `by` (canvas units).
|
||||
fn drag_handle(s: &mut Session, at: designcraft_geom::Point, by: designcraft_geom::Vec2, v: ViewInfo) {
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let p = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), at);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, p.x, p.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x / 2.0, p.y + by.y / 2.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, p.x + by.x, p.y + by.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, p.x + by.x, p.y + by.y), v).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_frame_resize_snaps_to_the_margins() {
|
||||
use designcraft_geom::{Point, Vec2};
|
||||
for zoom in [1.0, 0.5] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
|
||||
let (x0, y0) = (margin.x0 + 40.0, margin.y0 + 40.0);
|
||||
s.execute("frame.create", &json!({"rect": [x0, y0, x0 + 200.0, y0 + 200.0], "content": "text"})).unwrap();
|
||||
s.set_tool("selection");
|
||||
let v = ViewInfo::at_zoom(zoom);
|
||||
let b0 = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
// Right handle to 1.5 pt short of the right margin.
|
||||
drag_handle(&mut s, Point::new(b0.x1, b0.center().y), Vec2::new(margin.x1 - 1.5 - b0.x1, 0.0), v);
|
||||
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
assert!((b.x1 - margin.x1).abs() < 1e-6, "zoom {zoom}: right edge {} not on the margin {}", b.x1, margin.x1);
|
||||
// Bottom handle to 1.5 pt past the bottom margin.
|
||||
drag_handle(&mut s, Point::new(b.center().x, b.y1), Vec2::new(0.0, margin.y1 + 1.5 - b.y1), v);
|
||||
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
assert!((b.y1 - margin.y1).abs() < 1e-6, "zoom {zoom}: bottom edge {} not on the margin {}", b.y1, margin.y1);
|
||||
// Top-left corner handle onto the top and left margins.
|
||||
drag_handle(&mut s, Point::new(b.x0, b.y0), Vec2::new(margin.x0 + 1.0 - b.x0, margin.y0 - 1.0 - b.y0), v);
|
||||
let b = s.doc().unwrap().doc.spreads[0].items.last().unwrap().bounds();
|
||||
assert!((b.x0 - margin.x0).abs() < 1e-6 && (b.y0 - margin.y0).abs() < 1e-6, "zoom {zoom}: corner {b:?} not on the margins {margin:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_frames_snap_to_the_margins() {
|
||||
use designcraft_geom::Point;
|
||||
use designcraft_tools::{PointerEvent, PointerKind};
|
||||
let mut s = session();
|
||||
let margin = s.doc().unwrap().doc.spreads[0].pages[0].margin_rect();
|
||||
s.set_tool("type");
|
||||
let v = ViewInfo::at_zoom(1.0);
|
||||
let c = |s: &Session, x: f64, y: f64| s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), Point::new(x, y));
|
||||
// From 1.5 pt off the top-left margin corner to 1.5 pt off the right margin.
|
||||
let a = c(&s, margin.x0 + 1.5, margin.y0 + 1.5);
|
||||
let b = c(&s, margin.x1 - 1.5, margin.y0 + 101.5);
|
||||
let before = s.doc().unwrap().doc.spreads[0].items.len();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, a.x, a.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, (a.x + b.x) / 2.0, b.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, b.x, b.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, b.x, b.y), v).unwrap();
|
||||
let items = &s.doc().unwrap().doc.spreads[0].items;
|
||||
assert_eq!(items.len(), before + 1, "a frame was drawn");
|
||||
let r = items.last().unwrap().bounds();
|
||||
assert!((r.x0 - margin.x0).abs() < 1e-6 && (r.y0 - margin.y0).abs() < 1e-6, "press point {r:?} vs {margin:?}");
|
||||
assert!((r.x1 - margin.x1).abs() < 1e-6, "right edge {} vs {}", r.x1, margin.x1);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,91 @@ fn magazine_round_trips_through_idml() {
|
||||
assert_eq!(items(&again.spreads).len(), items(&back.spreads).len());
|
||||
}
|
||||
|
||||
/// A one-page IDML package: two threaded frames side by side, 19 lines each (10 pt on 12 pt),
|
||||
/// paragraphs of `lines` forced-break lines in style `Body`, which carries `body_attrs`.
|
||||
fn keep_lines_idml(body_attrs: &str, lines: &[usize]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let paras: String = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(p, &n)| {
|
||||
let text = (0..n).map(|k| format!("P{p} line {k}")).collect::<Vec<_>>().join("\u{2028}");
|
||||
format!(
|
||||
r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/Body"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content>{}</CharacterStyleRange></ParagraphStyleRange>"#,
|
||||
if p + 1 < lines.len() { "<Br/>" } else { "" }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let frame = |me: &str, x: f64, prev: &str, next: &str| {
|
||||
let pts: String = [(x, 36.0), (x, 276.0), (x + 180.0, 276.0), (x + 180.0, 36.0)]
|
||||
.iter()
|
||||
.map(|(x, y)| format!(r#"<PathPointType Anchor="{x} {y}" LeftDirection="{x} {y}" RightDirection="{x} {y}"/>"#))
|
||||
.collect();
|
||||
format!(
|
||||
r#"<TextFrame Self="{me}" ParentStory="s1" PreviousTextFrame="{prev}" NextTextFrame="{next}" ItemTransform="1 0 0 1 0 0"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>{pts}</PathPointArray></GeometryPathType></PathGeometry></Properties></TextFrame>"#
|
||||
)
|
||||
};
|
||||
let designmap = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Document xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0" Self="d">
|
||||
<DocumentPreference PageWidth="450" PageHeight="312" FacingPages="false"/>
|
||||
<RootCharacterStyleGroup Self="rc"><CharacterStyle Self="CharacterStyle/$ID/[No character style]" Name="$ID/[No character style]"/></RootCharacterStyleGroup>
|
||||
<RootParagraphStyleGroup Self="rp">
|
||||
<ParagraphStyle Self="ParagraphStyle/$ID/[No paragraph style]" Name="$ID/[No paragraph style]" PointSize="12"/>
|
||||
<ParagraphStyle Self="ParagraphStyle/Body" Name="Body" PointSize="10" {body_attrs}><Properties><BasedOn type="object">ParagraphStyle/$ID/[No paragraph style]</BasedOn><Leading type="unit">12</Leading></Properties></ParagraphStyle>
|
||||
</RootParagraphStyleGroup>
|
||||
<Story Self="s1">{paras}</Story>
|
||||
<Spread Self="sp1" ItemTransform="1 0 0 1 0 0"><Page Self="p1" GeometricBounds="0 0 312 450" ItemTransform="1 0 0 1 0 0"/>{}{}</Spread>
|
||||
</Document>"#,
|
||||
frame("t1", 36.0, "n", "t2"),
|
||||
frame("t2", 234.0, "t1", "n")
|
||||
);
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("mimetype", stored).unwrap();
|
||||
w.write_all(b"application/vnd.adobe.indesign-idml-package").unwrap();
|
||||
w.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(designmap.as_bytes()).unwrap();
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// Lines of paragraph `para` in each frame of the story.
|
||||
fn para_lines_per_frame(d: &Document, para: usize) -> Vec<usize> {
|
||||
let sid = *d.stories.keys().next().unwrap();
|
||||
let cs = designcraft_compose::compose_story(d, sid, &Default::default());
|
||||
cs.frames.iter().map(|f| f.lines.iter().filter(|l| l.para == para).count()).collect()
|
||||
}
|
||||
|
||||
/// Keep Lines Together without a mode in the IDML: InDesign's default, at start/end of paragraph.
|
||||
/// A 10-line paragraph starting 6 lines above the first frame's bottom splits 6/4.
|
||||
#[test]
|
||||
fn idml_keep_lines_without_mode_split_at_start_and_end() {
|
||||
let keep = r#"KeepLinesTogether="true" KeepFirstLines="2" KeepLastLines="2""#;
|
||||
let d = designcraft_idml::import_idml(&keep_lines_idml(keep, &[13, 10, 2])).unwrap();
|
||||
assert_eq!(para_lines_per_frame(&d, 0), [13, 0]);
|
||||
assert_eq!(para_lines_per_frame(&d, 1), [6, 4]);
|
||||
// All lines in paragraph, written explicitly: the paragraph moves whole.
|
||||
let d = designcraft_idml::import_idml(&keep_lines_idml(&format!(r#"{keep} KeepAllLinesTogether="true""#), &[13, 10, 2])).unwrap();
|
||||
assert_eq!(para_lines_per_frame(&d, 1), [0, 10]);
|
||||
}
|
||||
|
||||
/// A document saved in format 1 with Keep Lines Together and no mode still keeps all lines
|
||||
/// together after loading; saved now, it keeps lines at start/end of paragraph.
|
||||
#[test]
|
||||
fn format_1_keep_lines_without_mode_lays_out_all_lines() {
|
||||
use std::io::Write;
|
||||
let d = designcraft_idml::import_idml(&keep_lines_idml(r#"KeepLinesTogether="true""#, &[13, 10, 2])).unwrap();
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
w.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(br#"{"format": "designcraft", "version": 1}"#).unwrap();
|
||||
w.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(&serde_json::to_vec(&d).unwrap()).unwrap();
|
||||
let v1 = designcraft_format::load(&w.finish().unwrap().into_inner()).unwrap();
|
||||
assert_eq!(para_lines_per_frame(&v1, 1), [0, 10]);
|
||||
let now = designcraft_format::load(&designcraft_format::save(&d).unwrap()).unwrap();
|
||||
assert_eq!(para_lines_per_frame(&now, 1), [6, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idml_commands() {
|
||||
let mut s = Session::new();
|
||||
@@ -251,3 +336,30 @@ fn named_lists_round_trip_through_idml() {
|
||||
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
|
||||
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_para_sets_a_drop_cap_that_survives_idml() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "Initial", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
let text = "Once upon a time there was a paragraph long enough to run beside its drop cap for a few lines and then some more.";
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap();
|
||||
s.execute("type.para", &json!({"attrs": {"dropCapLines": 3, "dropCapChars": 1, "dropCapStyle": "Initial", "dropCapAlignLeft": false}})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default());
|
||||
let lines = &cs.frames[0].lines;
|
||||
let dc = &lines[0].glyphs[0];
|
||||
assert_eq!(dc.byte, 0);
|
||||
assert!((lines[0].baseline + dc.y - lines[2].baseline).abs() < 1e-6, "on line 3's baseline");
|
||||
assert!(lines[0].drop_cap.is_some_and(|b| b.end == 1));
|
||||
let p = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].para;
|
||||
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_style.as_deref()), (Some(3), Some(1), Some("Initial")));
|
||||
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
|
||||
let p = &back.stories.values().find(|st| st.text.starts_with("Once")).unwrap().paras[0].para;
|
||||
assert_eq!((p.drop_cap_lines, p.drop_cap_chars, p.drop_cap_align_left), (Some(3), Some(1), Some(false)));
|
||||
// The drop cap's character style goes through IDML as a leading Dropcap nested style.
|
||||
assert_eq!(p.drop_cap_style.as_deref(), Some("Initial"));
|
||||
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
|
||||
}
|
||||
|
||||
@@ -16,6 +16,321 @@ fn check(s: &Session) {
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_text_replacement_floors_utf8_selection_and_allows_quote_typing() {
|
||||
for (original, (a, b), replacement, (after_a, after_b), quoted) in [
|
||||
("a", (1, 1), "é", (0, 0), Some("“é")), // Exact caret/quote baseline.
|
||||
("abc", (3, 3), "🙂", (0, 0), Some("“🙂")),
|
||||
("ab", (1, 2), "éX", (0, 2), None),
|
||||
("ab", (2, 1), "éX", (2, 0), None),
|
||||
("abcdef", (3, 5), "🙂éX", (0, 4), None),
|
||||
("abcdef", (5, 3), "🙂éX", (4, 0), None),
|
||||
("abc", (1, 2), "🙂", (0, 0), None), // Both endpoints floor to the same boundary.
|
||||
("abcdef", (2, 6), "é🙂", (2, 6), None), // Valid Unicode endpoints stay fixed.
|
||||
("abcdef", (1, 4), "ABCDEF", (1, 4), None),
|
||||
("abcdef", (4, 1), "ABCDEF", (4, 1), None),
|
||||
("abcdef", (6, 6), "é", (2, 2), None), // Shortening clamps to EOF.
|
||||
("abc", (1, 3), "", (0, 0), None),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": ""})).unwrap();
|
||||
let sid = frame["story"].as_u64().unwrap();
|
||||
let fid = frame["id"].as_u64().unwrap();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap();
|
||||
// Real commands position the child selection; text.select addresses parent stories.
|
||||
for _ in a..original.len() {
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
}
|
||||
let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) };
|
||||
for _ in 0..steps {
|
||||
s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap();
|
||||
}
|
||||
let mut expected = s.doc().unwrap().selection.clone();
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
assert_eq!((selected.anchor, selected.focus), (a, b));
|
||||
assert_eq!(selected.frame, Some(doc::ItemId(fid)));
|
||||
assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 }));
|
||||
(selected.anchor, selected.focus) = (after_a, after_b);
|
||||
let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text.clone();
|
||||
s.execute("table.setCell", &json!({"text": replacement})).unwrap();
|
||||
assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, replacement);
|
||||
assert_eq!(s.doc().unwrap().selection, expected, "{original}: {a}->{b}, replacement {replacement}");
|
||||
assert!(replacement.is_char_boundary(after_a) && replacement.is_char_boundary(after_b));
|
||||
assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, parent);
|
||||
if let Some(quoted) = quoted {
|
||||
assert!(s.prefs.typographers_quotes);
|
||||
s.execute("text.insert", &json!({"text": "\""})).unwrap();
|
||||
assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, quoted);
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
(selected.anchor, selected.focus) = (3, 3); // Opening curly quote is three bytes.
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
}
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_edit_after_anchor_removal_floors_parent_caret() {
|
||||
for (original, (a, b), replacement, pos, quoted, typed_pos) in [("ab", (1, 2), "é", 0, "“é\n", 3), ("abcde", (5, 1), "é🙂", 2, "é”🙂\n", 5)]
|
||||
{
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let other = s.execute("frame.create", &json!({"rect": [320, 36, 580, 200], "content": "text"})).unwrap();
|
||||
let other_sid = other["story"].as_u64().unwrap();
|
||||
let other_tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("text.insert", &json!({"text": "keep", "raw": true})).unwrap();
|
||||
let mut expected_other = table_for(&s, other_sid, other_tid).clone();
|
||||
expected_other.cell_mut(0, 0).unwrap().insets = [7.0; 4];
|
||||
|
||||
let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
|
||||
let sid = frame["story"].as_u64().unwrap();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap();
|
||||
for _ in a..original.len() {
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
}
|
||||
let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) };
|
||||
for _ in 0..steps {
|
||||
s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap();
|
||||
}
|
||||
let mut expected = s.doc().unwrap().selection.clone();
|
||||
let selected = expected.text.unwrap();
|
||||
assert_eq!((selected.anchor, selected.focus), (a, b));
|
||||
assert_eq!(selected.frame, Some(doc::ItemId(frame["id"].as_u64().unwrap())));
|
||||
assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 }));
|
||||
// Explicit parent replacement removes its anchor, leaving the child address stale.
|
||||
assert_eq!(
|
||||
s.execute("find.change", &json!({"story": sid, "find": doc::TABLE_ANCHOR.to_string(), "change": replacement})).unwrap()["count"],
|
||||
1
|
||||
);
|
||||
let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap();
|
||||
assert_eq!(parent.text, format!("{replacement}\n"));
|
||||
assert!(!parent.tables.contains_key(&tid));
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
assert!(!parent.text.is_char_boundary(selected.anchor));
|
||||
// An explicit edit to the surviving table reaches the disappeared-table fallback.
|
||||
s.execute("table.setCell", &json!({"story": other_sid, "table": other_tid, "insets": 7})).unwrap();
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
(selected.anchor, selected.focus, selected.cell) = (pos, pos, None); // Both floor the OLD anchor.
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, format!("{replacement}\n"));
|
||||
assert_eq!(table_for(&s, other_sid, other_tid), &expected_other);
|
||||
s.execute("text.insert", &json!({"text": "\""})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, quoted);
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
(selected.anchor, selected.focus) = (typed_pos, typed_pos);
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
assert_eq!(table_for(&s, other_sid, other_tid), &expected_other);
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) {
|
||||
let (mut s, sid, fid) = session_with_frame();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
for (i, text) in ["A", "B", "C", "D"].iter().enumerate() {
|
||||
s.execute("text.insert", &json!({"text": text})).unwrap();
|
||||
if i < 3 {
|
||||
s.execute("table.nextCell", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
let merge = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) };
|
||||
s.execute("table.select", &merge).unwrap();
|
||||
s.execute("table.merge", &json!({})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [1, 1], "cols": [1, 1]})).unwrap();
|
||||
s.execute(if column { "table.selectColumn" } else { "table.selectRow" }, &json!({})).unwrap();
|
||||
(s, sid, tid, fid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_merged_owners_receive_text_formatting_and_character_styles() {
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(column);
|
||||
let selected = s.doc().unwrap().selection.clone();
|
||||
assert_eq!(selected.cells.unwrap().range, if column { doc::CellRange::new(0, 1, 1, 1) } else { doc::CellRange::new(1, 0, 1, 1) });
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let neighbor = if column { (1, 0) } else { (0, 1) };
|
||||
s.execute("type.char", &json!({"attrs": {"size": 23}})).unwrap();
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
let cell = table_for(&s, sid, tid).cell(r, c).unwrap();
|
||||
assert!(cell.text.runs().all(|(_, f)| f.over.size == Some(23.0)), "all runs of owner {r},{c}");
|
||||
assert_eq!(cell.text.text, before.cell(r, c).unwrap().text.text);
|
||||
}
|
||||
assert_eq!(table_for(&s, sid, tid).cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(s.doc().unwrap().selection, selected);
|
||||
s.execute("style.character.create", &json!({"name": "Selected owners", "chars": {"tracking": 37}})).unwrap();
|
||||
s.execute("style.character.apply", &json!({"name": "Selected owners"})).unwrap();
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
assert!(table_for(&s, sid, tid).cell(r, c).unwrap().text.runs().all(|(_, f)| f.style == "Selected owners"));
|
||||
}
|
||||
let after = table_for(&s, sid, tid);
|
||||
assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(after.rows, before.rows);
|
||||
assert_eq!(after.columns, before.columns);
|
||||
assert_eq!(s.doc().unwrap().selection, selected, "formatting preserves the original grid rectangle");
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_merged_owners_receive_cell_options_and_unique_style_counts() {
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(column);
|
||||
let range = s.doc().unwrap().selection.cells.unwrap().range;
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let neighbor = if column { (1, 0) } else { (0, 1) };
|
||||
let covered = if column { (0, 1) } else { (1, 0) };
|
||||
s.execute("table.setCell", &json!({"fill": "[Black]", "insets": 7, "text": "REPLACED"})).unwrap();
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
let cell = table_for(&s, sid, tid).cell(r, c).unwrap();
|
||||
assert_eq!(serde_json::to_value(&cell.fill).unwrap(), json!("[Black]"));
|
||||
assert_eq!(cell.insets, [7.0; 4]);
|
||||
assert_eq!(cell.text.text, "REPLACED");
|
||||
}
|
||||
s.execute("style.cell.create", &json!({"name": "Selected cell owners", "insets": 9})).unwrap();
|
||||
assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 2);
|
||||
let after = table_for(&s, sid, tid);
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
assert_eq!(after.cell(r, c).unwrap().style, "Selected cell owners");
|
||||
assert_eq!(after.cell(r, c).unwrap().insets, [9.0; 4]);
|
||||
}
|
||||
assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1));
|
||||
assert_eq!(after.rows, before.rows);
|
||||
assert_eq!(after.columns, before.columns);
|
||||
assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, range);
|
||||
let merged = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) };
|
||||
s.execute("table.select", &merged).unwrap();
|
||||
assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 1);
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_owner_strokes_keep_literal_edge_masks_and_neighbors() {
|
||||
for rtl in [false, true] {
|
||||
for outer in [false, true] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(true);
|
||||
if rtl {
|
||||
s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap();
|
||||
}
|
||||
s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap();
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
s.execute("table.setCell", &json!({"stroke": {"weight": 3, "edges": if outer { "outer" } else { "inner" }}})).unwrap();
|
||||
let after = table_for(&s, sid, tid);
|
||||
let mask = if rtl { [true, true, true, false] } else { [true, false, true, true] };
|
||||
let expected = mask.map(|edge| if outer { edge } else { !edge });
|
||||
let owner = after.cell(0, 0).unwrap();
|
||||
assert_eq!(owner.border_overrides, expected);
|
||||
assert_eq!(owner.stroke_defined, expected);
|
||||
for (i, on) in expected.into_iter().enumerate() {
|
||||
if on {
|
||||
assert_eq!(owner.strokes[i].weight, 3.0);
|
||||
assert_eq!(owner.stroke_priorities[i], 1);
|
||||
} else {
|
||||
assert_eq!(owner.strokes[i], before.cell(0, 0).unwrap().strokes[i]);
|
||||
}
|
||||
}
|
||||
for (r, c) in [(0, 1), (1, 0), (1, 1)] {
|
||||
assert_eq!(after.cell(r, c), before.cell(r, c), "strict merged/covered mirror guard preserves {r},{c}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, doc::CellRange::cell(0, 1));
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_style_sampling_resolves_covered_point_but_retains_upper_left_policy() {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(true);
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "insets": 7})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
s.execute("style.cell.create", &json!({"name": "Covered", "fromSelection": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Covered").unwrap().insets, Some([7.0; 4]));
|
||||
assert_eq!(table_for(&s, sid, tid), &before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap();
|
||||
s.execute("table.merge", &json!({})).unwrap();
|
||||
for (row, col, insets, size) in [(0, 1, 7, 17), (1, 0, 11, 29)] {
|
||||
s.execute("table.setCell", &json!({"row": row, "col": col, "text": "Text", "insets": insets})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [row, row], "cols": [col, col]})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": size}})).unwrap();
|
||||
}
|
||||
s.execute("table.selectRow", &json!({})).unwrap();
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
// Row-major owners are [(0,1),(1,0)], while the rectangle starts at (1,0).
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 17.0);
|
||||
s.execute("style.cell.create", &json!({"name": "Upper left", "fromSelection": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Upper left").unwrap().insets, Some([11.0; 4]));
|
||||
assert_eq!(table_for(&s, sid, tid), &before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_merged_owners_convert_graphics_back_to_text() {
|
||||
let png = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![255, 0, 0, 255] }.to_png();
|
||||
let image = crate::cmd::base64_encode(&png);
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(column);
|
||||
let neighbor = if column { (1, 0) } else { (0, 1) };
|
||||
let covered = if column { (0, 1) } else { (1, 0) };
|
||||
for (r, c) in [covered, (1, 1), neighbor] {
|
||||
let placed = s.execute("table.placeGraphic", &json!({"row": r, "col": c, "base64": image, "name": "cell.png"})).unwrap();
|
||||
if (r, c) == covered {
|
||||
assert_eq!((placed["row"].as_u64(), placed["col"].as_u64()), (Some(0), Some(0)));
|
||||
}
|
||||
}
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let selected = s.doc().unwrap().selection.cells.unwrap();
|
||||
s.execute("table.textCell", &json!({})).unwrap();
|
||||
let after = table_for(&s, sid, tid);
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
assert!(before.cell(r, c).unwrap().graphic.is_some());
|
||||
assert!(after.cell(r, c).unwrap().graphic.is_none());
|
||||
assert_eq!(after.cell(r, c).unwrap().text, before.cell(r, c).unwrap().text);
|
||||
}
|
||||
assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1));
|
||||
assert_eq!(s.doc().unwrap().selection.cells, Some(selected));
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_selected_owner_drag_keeps_merged_move_restrictions_atomic() {
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, _, fid) = selected_merged_table(column);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let redo = s.doc().unwrap().history.redo.len();
|
||||
let cs = s.cache.get(&before, doc::StoryId(sid), None);
|
||||
let cells = &cs.frames[0].tables[0].cells;
|
||||
let point = |row, col| {
|
||||
let cell = cells.iter().find(|c| c.row == row && c.col == col).unwrap();
|
||||
before.text_xf(before.item(doc::ItemId(fid)).unwrap()) * cell.rect.center()
|
||||
};
|
||||
let from = point(0, 0);
|
||||
let to = if column { point(1, 0) } else { point(0, 1) };
|
||||
let err = s.execute("table.dropCells", &json!({"frame": fid, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap_err();
|
||||
assert!(err.to_string().contains(if column { "unmerge cells that span columns" } else { "unmerge cells that span rows" }), "{err}");
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc, before);
|
||||
assert_eq!(st.selection, selection);
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_type_in_cells_and_tab() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
|
||||
Reference in New Issue
Block a user