Backspace after a floating shape's anchor keeps the shape (#4592) (#4641)

* Backspace after a floating shape's anchor joins paragraphs and keeps the shape (#4592)

* wc-upkeep-6: protect Backspace joins and markers

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Harrison Booth
2026-10-30 00:07:53 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 74b2cbb5c9
commit 2e790d549c
3 changed files with 68 additions and 2 deletions
+17 -2
View File
@@ -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);
}
+48
View File
@@ -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);
}
+3
View File
@@ -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) |