diff --git a/ROADMAP.md b/ROADMAP.md index 108da0860..601d73eac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: an option to show only the selected text's comment, #1055; previously trivial: progress log: a window too big for its screen shrinks to fit, #1215; 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: 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. @@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| | 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested | +| 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) | | 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused | | 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents | | 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it | @@ -349,6 +350,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Progress log: Insert › Bookmark deletes and goes to bookmarks (#1236) | | 2026-10-12 | trivial | Progress log: an option to show only the selected text's comment (#1055) | | 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) | | 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b5e742227..adf26522e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -132,7 +132,7 @@ pub fn specs() -> Vec { .params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#), CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto) .key("Mod+Alt+G / F5") - .params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string, "paragraph"?: n}; none opens Find and Replace on its Go To tab"#) + .params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#) .pure(), CommandSpec::new("edit.goBack", "Go Back", "Navigation", |s, _| { let p = s.go_back().ok_or_else(|| CmdError::Failed("no edits to go back to yet".into()))?; @@ -684,8 +684,9 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult { return sel_result(s); } if let Some(name) = p::str(v, "bookmark") { - let bm = s.doc.bookmarks().into_iter().find(|(n, _)| n == name).ok_or_else(|| CmdError::Failed(format!("no bookmark `{name}`")))?; - s.sel = Selection::caret(bm.1); + // As in Word, the bookmark's text is selected (a caret for an empty bookmark). + let (from, to) = super::insert::bookmark_range(&s.doc, name).ok_or_else(|| CmdError::Failed(format!("no bookmark `{name}`")))?; + s.sel = Selection { anchor: from, focus: to }; return sel_result(s); } if let Some(n) = p::u64(v, "paragraph") { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 6d744a46e..c4f60525d 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -44,6 +44,7 @@ pub fn specs() -> Vec { } })), CommandSpec::new("insert.bookmark", "Bookmark", "Insert › Links", bookmark).params(r#"{"name": string}"#).key("Mod+Shift+F5"), + CommandSpec::new("insert.deleteBookmark", "Delete Bookmark", "Insert › Links", delete_bookmark).params(r#"{"name": string}"#), CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#), CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#), CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#), @@ -394,20 +395,85 @@ fn link(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// Whether `name` can name a bookmark: no spaces, at most 40 characters, not starting with a digit. +pub fn bookmark_name_ok(name: &str) -> bool { + !name.is_empty() && !name.contains(' ') && name.len() <= 40 && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) +} + +/// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark. +pub fn bookmark_range(doc: &wordcraft_doc::Document, name: &str) -> Option<(Pos, Pos)> { + let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?; + Some(bookmark_span(doc, name, &start)) +} + +/// The text of bookmark `name` whose start mark is at `start`: from just after that mark to its +/// end mark — in a later paragraph when it spans several (the start again when it has no end). +pub fn bookmark_span(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> (Pos, Pos) { + let from = Pos { off: start.off.saturating_add(wordcraft_doc::para::OBJ.len_utf8()), ..start.clone() }; + let end = doc.para_paths(start.story).into_iter().skip_while(|p| *p != start.path).find_map(|path| { + let para = doc.para(start.story, &path)?; + let off = para.object_offsets().into_iter().find(|o| { + (path != start.path || *o > start.off) && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name) + })?; + Some(Pos { story: start.story, path, off }) + }); + let end = end.unwrap_or_else(|| from.clone()); + (from, end) +} + +/// Remove bookmark `name`'s start and end marks, in whichever paragraphs and stories they are, +/// keeping the selection on the same text. Returns whether the bookmark was there. +fn remove_bookmark(s: &mut Session, name: &str) -> Result { + let mark_len = wordcraft_doc::para::OBJ.len_utf8(); + let mut found = false; + let stories: Vec = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect(); + for story in stories { + for path in s.doc.para_paths(story) { + let Some(p) = s.doc.para(story, &path) else { continue }; + let mut marks = Vec::new(); + for off in p.object_offsets() { + match p.object_at(off) { + Some(InlineObject::BookmarkStart { name: n }) if n == name => { + found = true; + marks.push(off); + } + Some(InlineObject::BookmarkEnd { name: n }) if n == name => marks.push(off), + _ => {} + } + } + if marks.is_empty() { + continue; + } + let para = s.doc.para_mut(story, &path)?; + for off in marks.into_iter().rev() { + para.delete(off, off + mark_len)?; + for at in [&mut s.sel.anchor, &mut s.sel.focus] { + if at.story == story && at.path == path && at.off > off { + at.off = at.off.saturating_sub(mark_len); + } + } + } + } + } + s.clamp_selection(); + Ok(found) +} + +fn delete_bookmark(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim(); + if !remove_bookmark(s, name)? { + return Err(CmdError::Params(format!("no bookmark `{name}`"))); + } + sel_result(s) +} + fn bookmark(s: &mut Session, v: &Value) -> CmdResult { let name = p::req_str(v, "name")?.trim().to_string(); - if name.is_empty() || name.contains(' ') || name.len() > 40 || name.chars().next().is_some_and(|c| c.is_ascii_digit()) { + if !bookmark_name_ok(&name) { return Err(CmdError::Params("bookmark names start with a letter, have no spaces and at most 40 characters".into())); } // Replace an existing bookmark of the same name. - for (_, pos) in s.doc.bookmarks().into_iter().filter(|(n, _)| *n == name).rev() { - let para = s.doc.para_mut(pos.story, &pos.path)?; - para.delete(pos.off, pos.off + wordcraft_doc::para::OBJ.len_utf8())?; - let offs: Vec = para.object_offsets(); - if let Some(e) = offs.into_iter().find(|o| matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if *n == name)) { - para.delete(e, e + wordcraft_doc::para::OBJ.len_utf8())?; - } - } + remove_bookmark(s, &name)?; let (a, b) = s.sel.ordered(); let props = CharProps::default(); let end = s.doc.insert_object(&b, InlineObject::BookmarkEnd { name: name.clone() }, &props)?; @@ -940,6 +1006,57 @@ mod tests { assert!(base64_decode("!!!").is_none()); } + /// Every bookmark mark in the body, in order: (paragraph, start or end, name). + fn bookmark_marks(s: &Session) -> Vec<(usize, bool, String)> { + let mut out = Vec::new(); + for (i, path) in s.doc.para_paths(StoryRef::Body).iter().enumerate() { + let p = s.doc.para(StoryRef::Body, path).unwrap(); + for off in p.object_offsets() { + match p.object_at(off) { + Some(InlineObject::BookmarkStart { name }) => out.push((i, true, name.clone())), + Some(InlineObject::BookmarkEnd { name }) => out.push((i, false, name.clone())), + _ => {} + } + } + } + out + } + + /// Insert › Bookmark › Delete (#1236): a bookmark across two paragraphs loses both its marks, + /// the text and the selection stay where they were, and one undo brings it back. Go To + /// selects the bookmark's text, as in Word. + #[test] + fn deleting_a_bookmark_removes_its_start_and_end() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha beta\ngamma delta")); + s.sel = Selection { anchor: Pos::body(0, 6), focus: Pos::body(1, 5) }; + s.run("insert.bookmark", &json!({"name": "Across"})).unwrap(); + assert_eq!(bookmark_marks(&s), vec![(0, true, "Across".into()), (1, false, "Across".into())]); + s.run("edit.goto", &json!({"bookmark": "Across"})).unwrap(); + let picked = s.selected_text(); + assert!(picked.starts_with("beta") && picked.ends_with("gamma"), "{picked:?}"); + s.run("select.text", &json!({"text": "delta"})).unwrap(); + s.run("insert.deleteBookmark", &json!({"name": "Across"})).unwrap(); + assert!(bookmark_marks(&s).is_empty(), "{:?}", bookmark_marks(&s)); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha beta\ngamma delta"); + assert_eq!(s.selected_text(), "delta", "the selection stays on its text"); + assert!(s.run("insert.deleteBookmark", &json!({"name": "Across"})).is_err(), "gone"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(bookmark_marks(&s).len(), 2, "one undo step"); + } + + /// Adding a bookmark under a name in use moves it, and the old end goes too — it was only + /// looked for in the start's paragraph, so a bookmark across paragraphs left a stray end. + #[test] + fn adding_a_bookmark_again_moves_all_of_it() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha beta\ngamma delta")); + s.sel = Selection { anchor: Pos::body(0, 6), focus: Pos::body(1, 5) }; + s.run("insert.bookmark", &json!({"name": "Spot"})).unwrap(); + s.run("select.text", &json!({"text": "delta"})).unwrap(); + s.run("insert.bookmark", &json!({"name": "Spot"})).unwrap(); + assert_eq!(bookmark_marks(&s), vec![(1, true, "Spot".into()), (1, false, "Spot".into())]); + assert_eq!(crate::cmd::references::bookmark_text(&s.doc, "Spot").as_deref(), Some("delta")); + } + /// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after. #[test] fn symbol_takes_its_own_font() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 88b1a02c4..60b47281f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2044,7 +2044,10 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo && let Some(link) = link_under(app, layout, page, x, y, &pos) { if let Some(name) = link.strip_prefix('#') { - let _ = app.run("edit.goto", json!({"bookmark": name})); + // The caret goes to the bookmark's start, as in Word (Go To selects its text). + if app.run("edit.goto", json!({"bookmark": name})).is_ok() { + let _ = app.run("select.collapse", json!({})); + } } else if is_followable_link(&link) { app.canvas.open_url = Some(link); } else { @@ -3565,6 +3568,41 @@ mod tests { } } + /// Ctrl+click on a link to a bookmark puts the caret at the bookmark's start, as Word does; + /// only Go To selects the bookmark's text (#1236). + #[test] + fn a_link_to_a_bookmark_puts_the_caret_at_its_start() { + let mut a = + WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Alpha beta gamma\nSee here")), Default::default()); + a.run("select.text", json!({"text": "beta"})).unwrap(); + a.run("insert.bookmark", json!({"name": "Spot"})).unwrap(); + a.run("select.text", json!({"text": "here"})).unwrap(); + a.run("insert.link", json!({"url": "#Spot"})).unwrap(); + a.run("caret.docEnd", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + steps(&mut h, 6); + // Between the link's "h" and "e". + let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 5 }).unwrap(); + let at = screen(&mut h, c.x, c.top + c.height / 2.0); + h.event(egui::Event::PointerMoved(at)); + h.step(); + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND }; + h.event_modifiers(press, egui::Modifiers::COMMAND); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + steps(&mut h, 3); + let s = &h.state().session; + let (start, _) = wordcraft_engine::cmd::insert::bookmark_range(&s.doc, "Spot").unwrap(); + assert!(s.sel.is_collapsed(), "a caret, not a selection: {:?}", s.sel); + assert_eq!(s.sel.focus, start, "at the bookmark's start"); + } + /// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves /// the scrollable range. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a33ae7a36..d73a252cd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -267,6 +267,12 @@ pub enum Dialog { }, Bookmark { name: String, + /// Listed in document order (Word's Sort by Location) rather than by name. + by_location: bool, + /// Hidden bookmarks (`_Toc…`, `_Ref…`, made for tables of contents and cross-references) listed too. + hidden: bool, + /// A bookmark was deleted, so Cancel reads Close (as in Word). + deleted: bool, }, WordCount { stats: Value, @@ -1175,7 +1181,7 @@ impl Dialog { } } "link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text() }, - "bookmark" => Dialog::Bookmark { name: String::new() }, + "bookmark" => Dialog::Bookmark { name: bookmark_at_caret(&app.session), by_location: false, hidden: false, deleted: false }, "wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() }, "zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() }, "watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true }, @@ -2200,21 +2206,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::Bookmark { name } => { - ui.label(tl!("Bookmark name:")); - ui.text_edit_singleline(name); - let marks = app.session.doc.bookmarks(); - for (n, _) in &marks { - if ui.selectable_label(false, n).clicked() { - let _ = app.run("edit.goto", json!({"bookmark": n})); - } - } - let (ok, cancel) = buttons(ui, tl!("Add")); - if ok { - let _ = app.run("insert.bookmark", json!({"name": name})); - } - ok || cancel - } + Dialog::Bookmark { name, by_location, hidden, deleted } => bookmark(app, ui, name, by_location, hidden, deleted), Dialog::WordCount { stats } => { egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| { for (l, k) in [ @@ -3292,6 +3284,92 @@ fn apply_asian(app: &mut WordApp, asian: [bool; 6], was: [bool; 6]) { } } +/// The innermost bookmark around the caret, which the Bookmark dialog opens on (as Word does); +/// empty when the caret is in none. +fn bookmark_at_caret(s: &wordcraft_engine::Session) -> String { + let at = &s.sel.focus; + s.doc + .bookmarks() + .into_iter() + .rev() + .find(|(name, start)| !name.starts_with('_') && start <= at && *at <= wordcraft_engine::cmd::insert::bookmark_span(&s.doc, name, start).1) + .map(|(name, _)| name) + .unwrap_or_default() +} + +/// The bookmarks the Bookmark dialog lists: by name or in document order, the hidden ones +/// (`_Toc…`, `_Ref…`) only when asked for. +fn bookmark_names(doc: &wordcraft_doc::Document, by_location: bool, hidden: bool) -> Vec { + let mut names: Vec = doc.bookmarks().into_iter().map(|(n, _)| n).filter(|n| hidden || !n.starts_with('_')).collect(); + if !by_location { + names.sort_by_key(|n| n.to_lowercase()); + } + names +} + +/// Insert › Bookmark, laid out as Word's (#1236): the name box over the list, Add / Delete / Go To +/// beside them, Sort by and Hidden bookmarks below. Add (Enter) closes the dialog; Delete and Go +/// To leave it open, and once a bookmark is deleted Cancel reads Close. +fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_location: &mut bool, hidden: &mut bool, deleted: &mut bool) -> bool { + let marks = bookmark_names(&app.session.doc, *by_location, *hidden); + let t = Tokens::get(ui.ctx()); + let (mut add, mut delete, mut go_to) = (false, false, false); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.label(tl!("Bookmark name:")); + ui.add(egui::TextEdit::singleline(name).desired_width(240.0)); + egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| { + ui.set_width(232.0); + ui.set_height(150.0); + egui::ScrollArea::vertical().id_salt("bookmark_list").max_height(150.0).auto_shrink([false, false]).show(ui, |ui| { + for n in &marks { + if ui.selectable_label(*n == *name, n.as_str()).clicked() { + *name = n.clone(); + } + } + }); + }); + }); + ui.vertical(|ui| { + ui.add_space(18.0); + let listed = marks.iter().any(|n| n == name.trim()); + let w = vec2(90.0, 0.0); + add = ui.add_enabled(wordcraft_engine::cmd::insert::bookmark_name_ok(name.trim()), egui::Button::new(tl!("Add")).min_size(w)).clicked(); + delete = ui.add_enabled(listed, egui::Button::new(tl!("Delete")).min_size(w)).clicked(); + go_to = ui.add_enabled(listed, egui::Button::new(tl!("Go To")).min_size(w)).clicked(); + }); + }); + ui.add_space(6.0); + ui.horizontal_top(|ui| { + ui.label(tl!("Sort by:")); + ui.vertical(|ui| { + ui.radio_value(by_location, false, tl!("Name")); + ui.radio_value(by_location, true, tl!("Location")); + }); + }); + ui.checkbox(hidden, tl!("Hidden bookmarks")); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + if add || (enter && wordcraft_engine::cmd::insert::bookmark_name_ok(name.trim())) { + let _ = app.run("insert.bookmark", json!({"name": name.trim()})); + return true; + } + if delete && app.run("insert.deleteBookmark", json!({"name": name.trim()})).is_ok() { + name.clear(); + *deleted = true; + } + if go_to { + let _ = app.run("edit.goto", json!({"bookmark": name.trim()})); + } + ui.add_space(8.0); + let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape)); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + close |= ui.button(if *deleted { tl!("Close") } else { tl!("Cancel") }).clicked(); + }); + }); + close +} + /// Manage Styles' body. Reads the list from `styles.manage`'s data (never re-running the command, /// which would ask to open this dialog again) and acts through commands. fn manage_styles(app: &mut WordApp, ui: &mut Ui, alphabetical: &mut bool, selected: &mut String) -> bool { @@ -3715,6 +3793,56 @@ mod tests { } } + /// Insert › Bookmark (#1236), as in Word: it opens on the bookmark around the caret, lists by + /// name, Go To selects a bookmark's text and Delete removes one — both leaving the dialog + /// open, Cancel then reading Close — and one undo brings the bookmark back. + #[test] + fn the_bookmark_dialog_goes_to_and_deletes_bookmarks() { + fn press(h: &mut egui_kittest::Harness<'_, WordApp>, label: &str) { + use egui_kittest::kittest::Queryable; + let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "Bookmark")))).expect("the dialog is open"); + h.query_all_by_label(label).find(|n| dialog.contains(n.rect().center())).expect(label).click(); + for _ in 0..3 { + h.step(); + } + } + let mut app = app_with("Alpha beta gamma"); + for (word, name) in [("Alpha", "_Hidden"), ("beta", "Zulu"), ("gamma", "Yankee")] { + let _ = app.run("select.text", json!({"text": word})); + let _ = app.run("insert.bookmark", json!({"name": name})); + } + let doc = &app.session.doc; + assert_eq!(bookmark_names(doc, false, false), ["Yankee", "Zulu"], "by name, hidden ones left out"); + assert_eq!(bookmark_names(doc, true, false), ["Zulu", "Yankee"], "by location"); + assert_eq!(bookmark_names(doc, false, true), ["_Hidden", "Yankee", "Zulu"]); + let _ = app.run("select.text", json!({"text": "gamma"})); + app.dialog = Dialog::open("bookmark", &mut app); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + assert!(matches!(&h.state().dialog, Some(Dialog::Bookmark { name, .. }) if name == "Yankee"), "opens on the caret's bookmark"); + press(&mut h, "Zulu"); + press(&mut h, "Go To"); + assert_eq!(h.state().session.selected_text(), "beta"); + press(&mut h, "Delete"); + let names: Vec = h.state().session.doc.bookmarks().into_iter().map(|(n, _)| n).collect(); + assert_eq!(names, ["_Hidden", "Yankee"]); + assert!(matches!(&h.state().dialog, Some(Dialog::Bookmark { name, deleted: true, .. }) if name.is_empty()), "still open"); + assert_eq!(h.state().session.selected_text(), "beta", "the text stays selected"); + press(&mut h, "Close"); + assert!(h.state().dialog.is_none()); + let _ = h.state_mut().run("edit.undo", json!({})); + assert_eq!(h.state().session.doc.bookmarks().len(), 3, "one undo step"); + } + /// Find, Replace and Go To are one dialog with three tabs (#712). #[test] fn find_replace_and_go_to_are_one_dialog() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 573aa785e..ce4a7100d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -129,6 +129,9 @@ Bold Получер Bookmark Отметка Bookmark name: Име на отметката: + Delete Bookmark Изтриване на отметка + Hidden bookmarks Скрити отметки + Sort by: Сортиране по: Border Painter Рисуване на граници Border\nPainter Рисуване\nна граници Borders Граници diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eed4f33a5..9aaed9095 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -140,6 +140,9 @@ Book Title Název knihy Bookmark Záložka Bookmark name: Název záložky: + Delete Bookmark Odstranit záložku + Hidden bookmarks Skryté záložky + Sort by: Seřadit podle: Border Painter Štětec ohraničení Border\nPainter Štětec\nohraničení Borders Ohraničení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 068bc60db..93e6b9e8e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -141,6 +141,9 @@ Bold Fett Bookmark Textmarke Bookmark name: Textmarkenname: + Delete Bookmark Textmarke löschen + Hidden bookmarks Ausgeblendete Textmarken + Sort by: Sortieren nach: Border Painter Rahmenlinien-Maler Border\nPainter Rahmenlinien-\nMaler Borders Rahmenlinien diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 248004389..910871ec3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -168,6 +168,9 @@ Bold Negrita Bookmark Marcador Bookmark name: Nombre del marcador: + Delete Bookmark Eliminar marcador + Hidden bookmarks Marcadores ocultos + Sort by: Ordenar por: Book Title Título de libro Border Painter Pincel de bordes Border\nPainter Pincel de\nbordes diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index eb015d8bd..4624c3f4d 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -80,6 +80,9 @@ Bold Paks Bookmark Järjehoidja Bookmark name: Järjehoidja nimi: + Delete Bookmark Kustuta järjehoidja + Hidden bookmarks Peidetud järjehoidjad + Sort by: Sordi: Book Title Raamatu pealkiri Border Painter Äärisepintsel Border\nPainter Äärise-\npintsel diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2a7f56fe3..2fd447dcb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -127,6 +127,9 @@ Bold Gras Bookmark Signet Bookmark name: Nom du signet : + Delete Bookmark Supprimer le signet + Hidden bookmarks Signets masqués + Sort by: Trier par : Border Painter Pinceau de bordure Border\nPainter Pinceau\nde bordure Borders Bordures diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f30907cca..8bc2aec22 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -87,6 +87,9 @@ Book Title Könyvcím Bookmark Könyvjelző Bookmark name: Könyvjelző neve: + Delete Bookmark Könyvjelző törlése + Hidden bookmarks Rejtett könyvjelzők + Sort by: Rendezés: Border Painter Szegélyfestő Border\nPainter Szegély-\nfestő Borders Szegélyek diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1bfc93f8f..653b537c7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -126,6 +126,9 @@ Bold Grassetto Bookmark Segnalibro Bookmark name: Nome segnalibro: + Delete Bookmark Elimina segnalibro + Hidden bookmarks Segnalibri nascosti + Sort by: Ordina per: Border Painter Copia bordi Border\nPainter Copia\nbordi Borders Bordi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 226220b0c..287f9e6fd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -185,6 +185,9 @@ Bold 太字 Bookmark ブックマーク Bookmark name: ブックマーク名: + Delete Bookmark ブックマークの削除 + Hidden bookmarks 隠しブックマーク + Sort by: 並べ替え: Border Painter 罫線の書式設定 Border\nPainter 罫線の書式設定 Borders 罫線 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5896d0484..ceb54c4a6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -83,6 +83,9 @@ Bold Pogrubienie Bookmark Zakładka Bookmark name: Nazwa zakładki: + Delete Bookmark Usuń zakładkę + Hidden bookmarks Ukryte zakładki + Sort by: Sortuj według: Border Painter Pędzel krawędzi Border\nPainter Pędzel\nkrawędzi Borders Krawędzie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a238e786a..372ca915b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -164,6 +164,9 @@ Bold Negrito Bookmark Indicador Bookmark name: Nome do indicador: + Delete Bookmark Excluir indicador + Hidden bookmarks Indicadores ocultos + Sort by: Classificar por: Border Painter Pincel de bordas Border\nPainter Pincel\nde bordas Borders Bordas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e8fb7e91e..7333adf54 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -85,6 +85,9 @@ Book Title Название книги Bookmark Закладка Bookmark name: Имя закладки: + Delete Bookmark Удалить закладку + Hidden bookmarks Скрытые закладки + Sort by: Сортировать по: Border Painter Кисть границ Border\nPainter Кисть\nграниц Borders Границы diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 7e5e5cdfa..9944d8cc8 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -242,6 +242,9 @@ Bold Podebljano Bookmark Obeleživač Bookmark name: Naziv obeleživača: + Delete Bookmark Obriši obeleživač + Hidden bookmarks Skriveni obeleživači + Sort by: Sortiraj po: Border Painter Kopiranje ivica Border\nPainter Kopiranje\nivica Borders Ivice diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 53f83ff45..db5618148 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -246,6 +246,9 @@ Bold Подебљано Bookmark Обележивач Bookmark name: Назив обележивача: + Delete Bookmark Обриши обележивач + Hidden bookmarks Скривени обележивачи + Sort by: Сортирај по: Border Painter Копирање ивица Border\nPainter Копирање\nивица Borders Ивице diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6b130c9ff..18fb2c010 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -84,6 +84,9 @@ Book Title Kitap Başlığı Bookmark Yer İşareti Bookmark name: Yer işareti adı: + Delete Bookmark Yer İşaretini Sil + Hidden bookmarks Gizli yer işaretleri + Sort by: Sıralama ölçütü: Border Painter Kenarlık Fırçası Border\nPainter Kenarlık\nFırçası Borders Kenarlıklar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5adb63c22..4cc6dc3c6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -166,6 +166,9 @@ Bold Напівжирний Bookmark Закладка Bookmark name: Назва закладки: + Delete Bookmark Видалити закладку + Hidden bookmarks Приховані закладки + Sort by: Сортувати за: Border Painter Пензель меж Border\nPainter Пензель\nмеж Borders Межі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1eaef77b9..68e05e217 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2601,3 +2601,8 @@ Show its number relative to this field Hiện số của nó tương đối với trường này Show its number with all higher levels Hiện số của nó kèm mọi cấp cao hơn Take the last match on the page Lấy kết quả khớp cuối cùng trên trang + +# Bookmark dialog (#1277) + Delete Bookmark Xóa dấu trang + Hidden bookmarks Dấu trang ẩn + Sort by: Sắp xếp theo: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b37bee859..d41d57836 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -197,6 +197,9 @@ Bold 加粗 Bookmark 书签 Bookmark name: 书签名: + Delete Bookmark 删除书签 + Hidden bookmarks 隐藏书签 + Sort by: 排序依据: Border Painter 边框刷 Border\nPainter 边框刷 Borders 边框 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 95e0cf329..0141e7fad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -186,6 +186,9 @@ Bold 粗體 Bookmark 書籤 Bookmark name: 書籤名稱: + Delete Bookmark 刪除書籤 + Hidden bookmarks 隱藏書籤 + Sort by: 排序依據: Border Painter 框線複製 Border\nPainter 框線複製 Borders 框線 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 531985d61..74e1f0aff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2203,7 +2203,7 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { "review.combine" if !has("path") && !has("text") => Some("combine"), "tools.autocorrect" if params.as_object().is_none_or(|m| m.is_empty()) => Some("autoCorrect"), "review.restrict" | "file.protect" if params.as_object().is_none_or(|m| m.is_empty()) => Some("restrictEditing"), - "insert.bookmark" if !has("name") => Some("bookmark"), + "insert.bookmark" | "insert.deleteBookmark" if !has("name") => Some("bookmark"), // Word Count from the keyboard or command search shows its dialog, as the ribbon does. "review.wordCount" if params.as_object().is_none_or(|m| m.is_empty()) => Some("wordCount"), // Content control Properties without settings shows the dialog. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c77848835..65434d828 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Cell Size Height/Width apply to the whole selection (#1242); previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (Bookmark dialog with Delete and Go To, #1236; previously trivial: the table context menu's Delete offers Delete Table with the caret in a cell, #1198; previously trivial: Update Field on a table of contents entry, #1197; previously trivial: Track Changes Options › Comments: show only the selected text's comment, beyond Word, #1055; previously trivial: Recipient List dialog gains Filter and Sort, #778; previously trivial: Ctrl+drag copies a picture, shape or text box, #1014; previously trivial: the mini toolbar scrolls under the ribbon, not over it; previously trivial: the mini toolbar steps aside for the ribbon; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -51,6 +51,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Insert › Bookmark as in Word (#1236): the name box over the list, Add / Delete / Go To beside them, Sort by Name or Location, Hidden bookmarks; it opens on the bookmark around the caret, Add (Enter) closes it, Delete and Go To keep it open (Cancel then reads Close), and Go To selects the bookmark's text, as Find and Replace › Go To does (Ctrl+click on a link to a bookmark only puts the caret at its start, as in Word). Not yet: with the caret in no bookmark Word offers the one added last; here the name box starts empty | | 2026-10-13 | trivial | Table context menu: Delete is a submenu with Delete Cells, Columns, Rows and Table whatever is selected, so the caret in a cell is enough to delete the table (#1198) | | 2026-10-12 | trivial | Track Changes Options › Comments: "In the margin" (default, as in Word) or "Only for the selected text", an option Word doesn't have (#1055). In the second mode the margin column is gone and only the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window); comment ranges stay shaded in the text. `review.trackingOptions {"commentDisplay": "margin"\|"selected"}` | | 2026-10-12 | trivial | On-canvas objects: Ctrl+drag copies a picture, shape or text box (`arrange.copyTo`, #1014) |