diff --git a/crates/engine/src/cmd/text/delete.rs b/crates/engine/src/cmd/text/delete.rs index b25a86fa30..5c692e523f 100644 --- a/crates/engine/src/cmd/text/delete.rs +++ b/crates/engine/src/cmd/text/delete.rs @@ -1,5 +1,5 @@ use super::enter_tab::tab_indent_on; -use super::{Block, CmdResult, NumRef, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result}; +use super::{Block, CmdResult, NumRef, OBJ, Pos, Selection, Session, StoryRef, Value, delete_selection, delete_selection_effect, sel_result}; pub(super) fn backspace(s: &mut Session, v: &Value) -> CmdResult { let r = backspace_inner(s, v)?; @@ -43,8 +43,17 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { }); return sel_result(s); } - let f = s.sel.focus.clone(); + let mut f = s.sel.focus.clone(); let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + // Only floating objects before the caret: they are anchors, not characters, so this is the + // paragraph start and the shape stays (Word). + let before = para.text.get(..f.off).unwrap_or_default(); + if !before.is_empty() + && before.chars().all(|c| c == OBJ) + && para.objects_with_offsets().take_while(|(o, _)| *o < f.off).all(|(_, o)| o.is_marker() || o.is_floating()) + { + f.off = 0; + } if f.off > 0 { let a = Pos { off: para.backspace_from(f.off), ..f.clone() }; if let Some(c) = s.autocap.as_mut() { @@ -98,6 +107,12 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { delete_selection(s)?; return sel_result(s); } + // Joining moves a content control whose start is at the paragraph edge. Check its + // contents explicitly: the paragraph-mark range itself ends before those contents. + for control in s.doc.control_ranges(f.story).into_iter().filter(|r| r.start.path == f.path && r.start.off == f.off) { + crate::cmd::controls::check_edit(s, &control.content_start(), &control.end)?; + } + crate::cmd::controls::check_delete(s, &a, &f)?; let at = s.doc.delete_range(&a, &f)?; s.sel = Selection::caret(at); } diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 4d2cc46d63..b154bfabca 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -3458,3 +3458,51 @@ fn double_space_adds_a_period_when_asked() { typ(&mut t, "Hello world"); assert_eq!(text(&t), "Hello world"); } + +#[test] +fn backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + let objects = |s: &Session| s.doc.para_at(&Pos::body(1, 0)).map(|p| p.object_offsets().len()); + assert_eq!(objects(&s), Some(1)); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 3}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(objects(&s), None); + assert_eq!( + s.doc.para_at(&Pos::body(0, 0)).map(|p| (p.text.replace('\u{FFFC}', ""), p.object_offsets().len())), + Some(("Line oneLine two".to_string(), 1)) + ); +} + +#[test] +fn backspace_after_marker_and_floating_anchors_still_joins_paragraphs() { + let mut s = typed("Line one\nLine two"); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + run(&mut s, "insert.textBox", json!({"text": "Box"})); + let (a, f) = (Pos::body(1, 0), Pos::body(1, 3)); + run(&mut s, "select.range", json!({"anchor": a, "focus": f})); + run(&mut s, "arrange.wrap", json!({"wrap": "inFrontOfText"})); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::BookmarkStart { name: "anchor".into() }, &Default::default()).unwrap(); + s.doc.insert_object(&Pos::body(1, 0), wordcraft_doc::InlineObject::CommentStart { id: 1 }, &Default::default()).unwrap(); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 9}})); + run(&mut s, "text.backspace", json!({})); + assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some()).count(), 1); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).map(|p| p.text.replace('\u{FFFC}', "")), Some("Line oneLine two".to_string())); +} + +#[test] +fn backspace_cannot_join_into_locked_content_control() { + let mut s = typed("Above\nInside"); + run(&mut s, "select.range", json!({"anchor": {"story": "body", "path": [1], "off": 0}, "focus": {"story": "body", "path": [1], "off": 6}})); + run(&mut s, "developer.richText", json!({"tag": "locked"})); + run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true})); + run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1], "off": 0}})); + let before = s.doc.clone(); + assert!(s.run("text.backspace", &json!({})).is_err()); + assert_eq!(s.doc, before); +} diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 8d488a46da..101f1443e9 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -81,6 +81,7 @@ Bullets and Numbering decide whether to toggle off from the selected paragraphs' | type, select some of it, type over it | two undo steps: the first ⌘Z brings the selected text back (still selected), the second removes the typing. Uninterrupted typing stays one step | `typing_then_selecting_then_typing_are_two_undo_steps` | | ⌘B with nothing selected, ⌘Z, type | the text isn't bold (Undo takes the waiting caret formatting back too); ⌘Y brings it back | `undoing_bold_at_the_caret_turns_bold_typing_off_again` | | ⌫ at the start of the document, or typing nothing | nothing happens: no undo step is added, the document is not marked edited, and the redo stack survives ³ | `backspace_at_the_start_of_the_document_is_not_an_edit`, `a_command_that_changes_nothing_is_not_an_edit`, `a_no_op_command_keeps_the_redo_stack` | +| ⌫ right after a floating shape's anchor at the paragraph start (In Front of Text, Square…) | joins the paragraph above and keeps the shape; the anchor is not a character to delete (checked in Word for Windows, #4592) | `backspace_after_a_floating_shape_anchor_joins_paragraphs_and_keeps_the_shape` | ³ Not re-observed side by side in Word for this change: Word's undo list does not grow when a key changes nothing, and the document is not marked edited. The gap was found by the read-only @@ -565,3 +566,5 @@ again. | 2026-10-10 | minor | Track Changes rows: tracked Enter, Backspace and Delete on paragraph marks (#244) | | 2026-10-10 | major | First version: lists, AutoCorrect and AutoFormat as you type, observed against Word (#204) | | 2026-10-15 | trivial | Text direction: typed and inserted text takes its direction from its own letters, not the caret's run; spaces typed into reopened Hebrew draw in the Latin font until reopened (#1543) | + +| 2026-10-27 | trivial | Backspace after a floating shape's anchor at a paragraph start joins paragraphs and keeps the shape (#4592) | \ No newline at end of file