mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
* contributors: add Nael-Nathanael Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * insert.picture and insert.icon leave the caret after the picture (#965) A script that typed right after inserting a picture replaced it, so the picture was missing from the saved file. Commands now leave the caret after the new picture; Picture Format commands still act on it. The app still selects a picture the user inserts, as in Word. Fixes #965 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Select a pasted picture again, keep pictures inserted in a row (#965) Desktop paste inserts through execute, so after the caret change it left a pasted picture unselected. WordApp::select_inserted_picture selects the picture just before the caret; run and the paste path call it, paste once after the last picture so a multi-file paste keeps them all. Tests for paste selection and for two inserts in a row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
9c57b950b9
commit
81b5602bf0
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: justified vertical page alignment, #1320; previously trivial: progress log: a picture inserted by a command survives the next typing, #965; previously trivial: Behind Text pictures and shapes can be dragged across overlaid text, in the body and headers, without taking ordinary text clicks; previously trivial: progress log: crop handles on the canvas; previously trivial: progress log: Insert › Bookmark deletes and goes to bookmarks, #1236; previously trivial: progress log: an option to show only the selected text's comment, #1055; previously trivial: #778 mail-merge recipient filter/sort, .xls and legacy encodings; previously trivial: progress log: clicking in a comment's text moves its caret, #1013; previously trivial: progress log: a press on the ribbon no longer scrolls the document, #1019; previously trivial: title rename and account settings; previously trivial: progress log: Down and Up no longer skip a table, #1007; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -268,6 +268,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
|
||||
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
|
||||
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
|
||||
| 2026-10-14 | A picture or icon inserted by a command (`wordcraft-cli run`, MCP, control channel) leaves the caret after it, so the next `text.newParagraph` or `text.insert` no longer deletes it, and two inserted in a row are both kept; Picture Format commands still act on it, and a picture the user inserts or pastes in the app is still selected (#965) |
|
||||
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
|
||||
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
|
||||
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
|
||||
@@ -449,6 +450,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
|
||||
| 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) |
|
||||
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
|
||||
| 2026-10-14 | trivial | Progress log: a picture inserted by a command survives the next typing (#965) |
|
||||
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
|
||||
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
|
||||
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when
|
||||
//! the next commands type (#965).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// A 1x1 PNG.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn typing_after_insert_picture_keeps_it_in_the_saved_file() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (pic, out) = (dir.join("pic.png"), dir.join("p.docx"));
|
||||
std::fs::write(&pic, PNG).unwrap();
|
||||
let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy()));
|
||||
let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#];
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.args(["run", "--template", "blank"]);
|
||||
for c in cmds {
|
||||
cli.args(["--cmd", c]);
|
||||
}
|
||||
let run = cli.arg("--save").arg(&out).output().unwrap();
|
||||
let docx = std::fs::read(&out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr));
|
||||
assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture");
|
||||
}
|
||||
@@ -84,10 +84,11 @@ display_name = "cyril265"
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
sync_github_name = false
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
[people.benandez]
|
||||
real_name = "Martin Ball"
|
||||
display_name = "Martin Ball"
|
||||
|
||||
@@ -223,7 +223,9 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Insert an encoded image at the selection as an inline picture, `width` points wide (default:
|
||||
/// its natural size at 96 ppi), never wider than the text column.
|
||||
/// its natural size at 96 ppi), never wider than the text column. The caret ends after it, so a
|
||||
/// script's next typing doesn't replace it (#965); Picture Format commands still act on it. The
|
||||
/// app selects a picture the person inserts (`WordApp::run`).
|
||||
fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str) -> CmdResult {
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
@@ -265,7 +267,7 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
let props = s.typing_props();
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
s.sel = Selection { anchor: at, focus: end };
|
||||
s.sel = Selection::caret(end);
|
||||
Ok(json!({"media": key, "width": w, "height": h}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1553,6 +1553,47 @@ mod tests {
|
||||
click_resize_and_drag("insert.picture", json!({"data": data, "width": 100}));
|
||||
}
|
||||
|
||||
/// #965: a picture or icon inserted by a command leaves the caret after it, so a script's next
|
||||
/// Enter or typing keeps it (and the saved file has it); Picture Format commands still reach it.
|
||||
#[test]
|
||||
fn typing_after_an_inserted_picture_keeps_it() {
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
for (id, params) in [("insert.picture", json!({"data": data, "width": 100})), ("insert.icon", json!({"id": "sun"}))] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run(id, ¶ms).unwrap();
|
||||
assert!(s.sel.is_collapsed(), "{id}: caret, not a selection");
|
||||
assert!(selected(&s).is_some(), "{id}: still the object Picture Format acts on");
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "cap"})).unwrap();
|
||||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1), "{id}");
|
||||
let paras: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => Some(p.text.replace(wordcraft_doc::para::OBJ, "#")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(paras, ["X", "#", "cap"], "{id}");
|
||||
let docx = crate::io::save_bytes("p.docx", &s.doc).unwrap();
|
||||
assert!(docx.windows(11).any(|w| w == b"word/media/"), "{id}: the saved file keeps the picture");
|
||||
}
|
||||
}
|
||||
|
||||
/// #965: two pictures inserted by commands in a row are both kept; the second doesn't
|
||||
/// replace the first.
|
||||
#[test]
|
||||
fn two_inserted_pictures_in_a_row_are_both_kept() {
|
||||
let data = super::super::insert::base64_encode(&png(20, 10));
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
s.run("insert.picture", &json!({"data": data})).unwrap();
|
||||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_pipeline() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -712,6 +712,21 @@ impl WordApp {
|
||||
self.session.view.draw.pens = std::mem::take(&mut self.ui.ink_pens);
|
||||
}
|
||||
|
||||
/// Select the picture or icon the person just inserted or pasted, so it shows its handles.
|
||||
/// `insert.picture` leaves the caret after it, which scripts and agents keep so their next
|
||||
/// typing doesn't replace it (#965).
|
||||
pub(crate) fn select_inserted_picture(&mut self, r: &Result<Value, String>) {
|
||||
if !r.as_ref().is_ok_and(|v| v.get("media").is_some() && v.get("canvasMember").is_none()) || !self.session.sel.is_collapsed() {
|
||||
return;
|
||||
}
|
||||
let end = self.session.sel.focus.clone();
|
||||
let Some(off) = end.off.checked_sub(wordcraft_doc::para::OBJ.len_utf8()) else { return };
|
||||
let start = wordcraft_doc::Pos { off, ..end.clone() };
|
||||
if self.session.doc.para_at(&start).and_then(|p| p.object_at(off)).is_some_and(|o| o.is_drawing()) {
|
||||
self.session.sel = wordcraft_engine::Selection { anchor: start, focus: end };
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open,
|
||||
/// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask
|
||||
/// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`).
|
||||
@@ -754,6 +769,9 @@ impl WordApp {
|
||||
return r;
|
||||
}
|
||||
let r = self.execute_user(id, params);
|
||||
if matches!(id, "insert.picture" | "insert.icon") {
|
||||
self.select_inserted_picture(&r);
|
||||
}
|
||||
let r = system_clipboard::explain(self, id, r);
|
||||
// Check Accessibility shows its findings in the Accessibility pane, kept up to date there.
|
||||
if id == "file.accessibility" && r.is_ok() {
|
||||
@@ -5829,6 +5847,24 @@ mod tests {
|
||||
a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// #965: a picture or icon the person inserts is selected, with handles, as in Word; one a
|
||||
/// script inserts (control channel, MCP bridge) leaves the caret after it, and typing keeps it.
|
||||
#[test]
|
||||
fn inserted_picture_is_selected_for_the_user_not_for_scripts() {
|
||||
let data = wordcraft_engine::cmd::insert::base64_encode(&png_bytes([200, 30, 30, 255]));
|
||||
for (id, params) in [("insert.picture", json!({"data": data})), ("insert.icon", json!({"id": "sun"}))] {
|
||||
let mut a = app();
|
||||
a.run(id, params.clone()).unwrap();
|
||||
assert!(crate::objects::selected(&a).is_some(), "{id}: the person's picture is selected");
|
||||
let mut a = app();
|
||||
a.execute(id, params).unwrap();
|
||||
assert!(a.session.sel.is_collapsed() && crate::objects::selected(&a).is_none(), "{id}: a script gets the caret after it");
|
||||
a.execute("text.newParagraph", json!({})).unwrap();
|
||||
a.execute("text.insert", json!({"text": "cap"})).unwrap();
|
||||
assert_eq!(object_count(&mut a), 1, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture pasted in the browser lands only when the page is the edit target (#1349);
|
||||
/// dropped or opened files always land.
|
||||
#[test]
|
||||
|
||||
@@ -121,6 +121,8 @@ pub(crate) fn paste(app: &mut WordApp) -> Option<Result<Value, String>> {
|
||||
r
|
||||
}
|
||||
};
|
||||
// Selected once, after the last picture: a selected picture is replaced by the next insert.
|
||||
app.select_inserted_picture(&r);
|
||||
Some(r)
|
||||
}
|
||||
|
||||
@@ -239,4 +241,25 @@ mod tests {
|
||||
let focus = a.session.sel.focus.clone();
|
||||
assert_eq!(a.session.doc.para_at(&focus).map(|p| p.text.contains("wc-ribbon-paste")), Some(false));
|
||||
}
|
||||
|
||||
/// A pasted picture is selected, with handles, as one inserted from Insert › Pictures is
|
||||
/// (#965); pasting several copied picture files keeps them all and selects the last.
|
||||
#[test]
|
||||
fn pasted_pictures_are_selected() {
|
||||
let mut a = app_with(|| Some(ClipboardPicture::Pixels { width: 4, height: 4, rgba: vec![200; 4 * 4 * 4] }));
|
||||
a.run("edit.paste", json!({})).unwrap();
|
||||
assert!(!a.session.sel.is_collapsed() && crate::objects::selected(&a).is_some(), "pixels: the pasted picture is selected");
|
||||
let dir = std::env::temp_dir().join(format!("wc-paste-965-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let files: Vec<PathBuf> = (0..2).map(|i| dir.join(format!("{i}.png"))).collect();
|
||||
for f in &files {
|
||||
std::fs::write(f, png_from_rgba(4, 4, vec![200; 4 * 4 * 4]).unwrap()).unwrap();
|
||||
}
|
||||
let mut a = app_with(move || Some(ClipboardPicture::Files(files.clone())));
|
||||
let r = a.run("edit.paste", json!({}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
r.unwrap();
|
||||
assert_eq!(pictures(&mut a), 2, "files: both pictures are kept");
|
||||
assert!(!a.session.sel.is_collapsed() && crate::objects::selected(&a).is_some(), "files: the last picture is selected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `parity` | feature coverage |
|
||||
| `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) |
|
||||
|
||||
`insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't
|
||||
replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it.
|
||||
|
||||
Resources: `wordcraft://document` (inspect) and `wordcraft://commands`.
|
||||
|
||||
The acceptance test `crates/mcp/src/tests.rs` writes a formatted document using MCP only.
|
||||
|
||||
Reference in New Issue
Block a user