mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
* Insert › Bookmark: Delete and Go To, laid out as Word's (#1236) The Bookmark dialog had only Add: a bookmark could not be deleted, and a click in its list jumped to the bookmark at once. It is now laid out as Word's (observed in Word 2019 on Windows): the name box over the list, Add, Delete and Go To beside them, Sort by Name or Location and Hidden bookmarks below. A click in the list picks a name; Add (Enter) adds and closes; Delete and Go To leave the dialog open, and after a delete Cancel reads Close. The dialog opens on the bookmark around the caret, and Add is off for a name the command would refuse. insert.deleteBookmark {name} removes a bookmark's start and end wherever they are, also across paragraphs, as one undo step, keeping the selection on its text; from the command search without a name it opens the dialog. Adding a bookmark under a name in use goes through the same removal: it looked for the old end only in the start's paragraph, so a bookmark across paragraphs left a stray end behind. edit.goto with a bookmark selects its text, as Word's Go To does, instead of placing the caret at its start. Closes #1236 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Insert › Bookmark: Ctrl+click on a link to a bookmark keeps a caret (#1236) Word's Go To selects a bookmark's text, but Ctrl+click on a link to a bookmark only puts the caret at the bookmark's start (checked in Word 2019 on Windows). Following a link runs edit.goto, which now selects, so it collapses the selection to its start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Resolve the doc conflict markers left by the previous merge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <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
44787764a9
commit
40cf85c25b
+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: 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) |
|
||||
|
||||
@@ -132,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.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") {
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
})),
|
||||
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<bool, CmdError> {
|
||||
let mark_len = wordcraft_doc::para::OBJ.len_utf8();
|
||||
let mut found = false;
|
||||
let stories: Vec<StoryRef> = 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<usize> = 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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+144
-16
@@ -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<String> {
|
||||
let mut names: Vec<String> = 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<String> = 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() {
|
||||
|
||||
@@ -129,6 +129,9 @@
|
||||
Bold Получер
|
||||
Bookmark Отметка
|
||||
Bookmark name: Име на отметката:
|
||||
Delete Bookmark Изтриване на отметка
|
||||
Hidden bookmarks Скрити отметки
|
||||
Sort by: Сортиране по:
|
||||
Border Painter Рисуване на граници
|
||||
Border\nPainter Рисуване\nна граници
|
||||
Borders Граници
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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í
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -185,6 +185,9 @@
|
||||
Bold 太字
|
||||
Bookmark ブックマーク
|
||||
Bookmark name: ブックマーク名:
|
||||
Delete Bookmark ブックマークの削除
|
||||
Hidden bookmarks 隠しブックマーク
|
||||
Sort by: 並べ替え:
|
||||
Border Painter 罫線の書式設定
|
||||
Border\nPainter 罫線の書式設定
|
||||
Borders 罫線
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -85,6 +85,9 @@
|
||||
Book Title Название книги
|
||||
Bookmark Закладка
|
||||
Bookmark name: Имя закладки:
|
||||
Delete Bookmark Удалить закладку
|
||||
Hidden bookmarks Скрытые закладки
|
||||
Sort by: Сортировать по:
|
||||
Border Painter Кисть границ
|
||||
Border\nPainter Кисть\nграниц
|
||||
Borders Границы
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -246,6 +246,9 @@
|
||||
Bold Подебљано
|
||||
Bookmark Обележивач
|
||||
Bookmark name: Назив обележивача:
|
||||
Delete Bookmark Обриши обележивач
|
||||
Hidden bookmarks Скривени обележивачи
|
||||
Sort by: Сортирај по:
|
||||
Border Painter Копирање ивица
|
||||
Border\nPainter Копирање\nивица
|
||||
Borders Ивице
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -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
|
||||
|
||||
|
@@ -166,6 +166,9 @@
|
||||
Bold Напівжирний
|
||||
Bookmark Закладка
|
||||
Bookmark name: Назва закладки:
|
||||
Delete Bookmark Видалити закладку
|
||||
Hidden bookmarks Приховані закладки
|
||||
Sort by: Сортувати за:
|
||||
Border Painter Пензель меж
|
||||
Border\nPainter Пензель\nмеж
|
||||
Borders Межі
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -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:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -197,6 +197,9 @@
|
||||
Bold 加粗
|
||||
Bookmark 书签
|
||||
Bookmark name: 书签名:
|
||||
Delete Bookmark 删除书签
|
||||
Hidden bookmarks 隐藏书签
|
||||
Sort by: 排序依据:
|
||||
Border Painter 边框刷
|
||||
Border\nPainter 边框刷
|
||||
Borders 边框
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -186,6 +186,9 @@
|
||||
Bold 粗體
|
||||
Bookmark 書籤
|
||||
Bookmark name: 書籤名稱:
|
||||
Delete Bookmark 刪除書籤
|
||||
Hidden bookmarks 隱藏書籤
|
||||
Sort by: 排序依據:
|
||||
Border Painter 框線複製
|
||||
Border\nPainter 框線複製
|
||||
Borders 框線
|
||||
|
||||
|
@@ -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.
|
||||
|
||||
+2
-1
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user