From 4a75c57f58cd37a6c1e227eda0b70ed464fdbaaa Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:38:06 +0200 Subject: [PATCH] Comment balloons edit in place, and the ruler drags page margins (#167) (#289) Clicking a comment balloon in the markup area selects it like Word: its text becomes editable in place, with Reply, Resolve/Reopen and Delete, and replies are shown (and editable) under the comment they answer. Each edit is written back through a new review.editComment command, one undo step per editing session; the Comments pane uses the same path (it used to write the text without an undo step) and gains a Reply button. The margin edges on the horizontal ruler (left/right) and the vertical ruler (top/bottom) can be dragged to set the section's margins through layout.margins, one undo step per drag, snapped to 1/16 inch and clamped so at least an inch of text remains. Indent markers keep priority where they overlap an edge. Setting margins by number no longer switches off mirrored margins; only a preset decides that. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/page.rs | 5 +- crates/engine/src/cmd/review.rs | 11 + crates/engine/src/tests.rs | 8 + crates/ui-egui/src/canvas.rs | 393 +++++++++++++++++++++++++--- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/sr-latn.tsv | 7 + crates/ui-egui/src/i18n/sr.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/panes.rs | 116 ++++++-- docs/control-protocol.md | 2 +- 15 files changed, 542 insertions(+), 51 deletions(-) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 043b0acce..3ac6b81db 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -134,7 +134,10 @@ fn margins(s: &mut Session, v: &Value) -> CmdResult { if l + r + gutter.unwrap_or(cur.gutter) > cur.page_w - 36.0 || t + b > cur.page_h - 36.0 { return Err(CmdError::Params("margins leave no room for text".into())); } - s.doc.settings.mirror_margins = preset == Some("mirrored"); + // A preset picks mirrored or not; plain numbers (a ruler drag, an agent) keep the setting. + if preset.is_some() { + s.doc.settings.mirror_margins = preset == Some("mirrored"); + } with_sect(s, |x| { x.margin_top = t.max(0.0); x.margin_bottom = b.max(0.0); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 27c920c55..42566fa31 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -11,6 +11,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key("Mod+Alt+M").params(r#"{"text": string}"#), CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#), + CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment).params(r#"{"id": n, "text": string}"#), CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#), CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| { let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?; @@ -132,6 +133,16 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"id": id})) } +/// Replace a comment's (or reply's) text; each line becomes a paragraph. +fn edit_comment(s: &mut Session, v: &Value) -> CmdResult { + let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?; + let text = p::req_str(v, "text")?; + let part = s.doc.comments.get(&id).map(|c| c.part).ok_or_else(|| CmdError::Params("no such comment".into()))?; + let blocks = text.split('\n').take(10_000).map(|l| para_block(Paragraph::with_text(l.trim_end_matches('\r'), Default::default()))).collect(); + s.doc.set_story(StoryRef::Part(part), blocks)?; + Ok(json!({"id": id})) +} + /// Comment anchored around the caret. fn comment_at_caret(s: &Session) -> Option { let f = &s.sel.focus; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 564b39dcf..37ecfecd4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1016,6 +1016,14 @@ fn comments() { let l = run(&mut s, "review.comments", json!({})); assert_eq!(l[0]["text"], "Nice"); assert_eq!(text(&s), "Some text here"); + // Editing the text is one undo step; replies hang off their comment. + run(&mut s, "review.editComment", json!({"id": id, "text": "Nicer\nTwo lines"})); + assert_eq!(run(&mut s, "review.comments", json!({}))[0]["text"], "Nicer\nTwo lines"); + assert!(s.undo()); + assert_eq!(run(&mut s, "review.comments", json!({}))[0]["text"], "Nice"); + assert!(s.run("review.editComment", &json!({"id": 999, "text": "x"})).is_err()); + let r = run(&mut s, "review.reply", json!({"id": id, "text": "Agreed"})); + assert_eq!(s.doc.comments.get(&(r["id"].as_u64().unwrap() as u32)).unwrap().parent, Some(id as u32)); run(&mut s, "review.deleteComment", json!({"id": id})); assert!(s.doc.comments.is_empty()); assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 17f209f29..c08dcd696 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,6 +53,16 @@ pub struct CanvasState { scroll_max: egui::Vec2, /// Pages per row last frame; when it changes the caret's page is scrolled back into view. pub cols: usize, + /// The comment whose balloon is selected (its text is editable in place, like Word's). + pub balloon: Option, + /// Give the selected balloon's text field keyboard focus on its next frame. + pub(crate) balloon_focus: bool, + /// The selected balloon shows a reply field. + pub(crate) balloon_reply: bool, + /// The selected balloon's height last frame (screen points), for its click area. + balloon_h: f32, + /// Screen rects of the comment balloons last frame (for the control channel and tests). + pub balloon_rects: Vec<(u32, Rect)>, } impl CanvasState { @@ -90,6 +100,11 @@ impl Default for CanvasState { scroll_offset: egui::Vec2::ZERO, scroll_max: egui::Vec2::ZERO, cols: 1, + balloon: None, + balloon_focus: false, + balloon_reply: false, + balloon_h: 0.0, + balloon_rects: Vec::new(), } } } @@ -609,6 +624,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.scroll_offset = out.state.offset; app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); let (resp, rects) = out.inner; + // A click on the page leaves the selected comment balloon. + if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() { + deselect_balloon(app, ui.ctx()); + } // Focus: the canvas takes keyboard focus on click and keeps Tab/arrows. if resp.clicked() || resp.drag_started() || app.canvas.want_focus { resp.request_focus(); @@ -672,24 +691,41 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } -/// Comment balloons in the markup area right of each page, joined to their anchors. +/// Comment balloons in the markup area right of each page, joined to their anchors, with each +/// comment's replies under it. Clicking a balloon selects it, like Word: its text becomes editable +/// in place, with Reply, Resolve and Delete (the same commands as the Comments pane). fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + app.canvas.balloon_rects.clear(); let mw = markup_width(app); + if app.canvas.balloon.is_some_and(|id| mw <= 0.0 || !app.session.doc.comments.contains_key(&id)) { + deselect_balloon(app, ui.ctx()); + } if mw <= 0.0 { return; } let t = Tokens::get(ui.ctx()); let clip = ui.clip_rect(); + // Replies hang under the comment they answer rather than getting balloons of their own. + let is_reply = |c: &wordcraft_doc::Comment| c.parent.is_some_and(|p| app.session.doc.comments.contains_key(&p)); + let mut replies: HashMap> = HashMap::new(); + for (id, c) in &app.session.doc.comments { + if let Some(p) = c.parent.filter(|_| is_reply(c)) { + replies.entry(p).or_default().push(*id); + } + } // (page, anchor x, anchor y, id) for each anchored, unresolved-or-not comment. let mut by_page: HashMap> = HashMap::new(); for (id, pos) in wordcraft_engine::cmd::review::comment_list(&app.session) { let Some(pos) = pos else { continue }; + if app.session.doc.comments.get(&id).is_some_and(is_reply) { + continue; + } let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue }; by_page.entry(c.page).or_default().push((c.x, c.top + c.height, id, pos)); } let palette = [t.blue, Color32::from_rgb(0xB0, 0x3A, 0x2E), Color32::from_rgb(0x2E, 0x7D, 0x32), Color32::from_rgb(0x8E, 0x44, 0xAD), t.orange]; let mut authors: Vec = Vec::new(); - let mut clicked: Option = None; + let mut clicked: Option<(u32, Pos)> = None; // The markup area extends each page. for (pi, pr) in rects.iter().enumerate() { let page_scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale)); @@ -699,62 +735,197 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re painter.line_segment([area.left_top(), area.left_bottom()], Stroke::new(1.0, Color32::from_rgb(0xE0, 0xE0, 0xE0))); } } - for (pi, list) in by_page.iter_mut() { - let Some(pr) = rects.get(*pi) else { continue }; + let mut pages: Vec = by_page.keys().copied().collect(); + pages.sort_unstable(); + for pi in pages { + let Some(list) = by_page.get_mut(&pi) else { continue }; + let Some(pr) = rects.get(pi) else { continue }; if !pr.expand(400.0).intersects(clip) { continue; } list.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0))); - let page_scale = layout.pages.get(*pi).map_or(scale, |page| page_screen_scale(*pr, page, scale)); + let page_scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale)); let x0 = pr.max.x + 10.0; let w = (mw * page_scale - 20.0).max(60.0); let mut next_y = pr.min.y; for (ax, ay, id, pos) in list.iter() { let Some(c) = app.session.doc.comments.get(id) else { continue }; - let text = app.session.doc.plain_text(StoryRef::Part(c.part)); - let author = if c.author.is_empty() { tl!("Author").to_string() } else { c.author.clone() }; + let resolved = c.resolved; + let author = author_name(&c.author); let ai = authors.iter().position(|a| *a == author).unwrap_or_else(|| { authors.push(author.clone()); authors.len() - 1 }); let color = palette.get(ai % palette.len()).copied().unwrap_or(t.blue); let fs = (11.0 * page_scale / PX_PER_PT).clamp(8.0, 16.0); - let head = painter.layout(author.clone(), semibold(fs), t.text, w - 16.0); - let body = painter.layout(text.trim().to_string(), regular(fs), if c.resolved { t.text_dim } else { t.text }, w - 16.0); - let h = head.size().y + body.size().y + 16.0; + let thread: Vec = replies.get(id).cloned().unwrap_or_default(); let anchor = pos2(pr.min.x + ax * page_scale, pr.min.y + ay * page_scale); let top = (anchor.y - 12.0).max(next_y); - let card = Rect::from_min_size(pos2(x0, top), vec2(w, h)); - next_y = card.max.y + 6.0; + let active = app.canvas.balloon == Some(*id); + // Lay out a read-only balloon's text: the comment, then each reply under a rule. + let mut lines: Vec<(std::sync::Arc, f32)> = Vec::new(); + let card = if active { + Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0))) + } else { + let dim = |r: bool| if r { t.text_dim } else { t.text }; + lines.push((painter.layout(author, semibold(fs), t.text, w - 16.0), 0.0)); + let body = app.session.doc.plain_text(StoryRef::Part(c.part)); + lines.push((painter.layout(body.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0)); + for rid in &thread { + let Some(r) = app.session.doc.comments.get(rid) else { continue }; + lines.push((painter.layout(author_name(&r.author), semibold(fs), t.text, w - 16.0), 9.0)); + let text = app.session.doc.plain_text(StoryRef::Part(r.part)); + lines.push((painter.layout(text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0)); + } + let h = lines.iter().map(|(g, gap)| g.size().y + gap).sum::() + 12.0; + Rect::from_min_size(pos2(x0, top), vec2(w, h)) + }; if !card.intersects(clip) { + next_y = card.max.y + 6.0; continue; } - let selected = app.session.sel.focus.path == pos.path && app.session.sel.focus.story == pos.story; + let selected = active || (app.session.sel.focus.path == pos.path && app.session.sel.focus.story == pos.story); // Leader: from the anchor along the text to the page edge, then to the card. let lead = Stroke::new(if selected { 1.5 } else { 1.0 }, color.linear_multiply(if selected { 1.0 } else { 0.7 })); dashed(painter, anchor, pos2(pr.max.x, anchor.y), lead); painter.line_segment([pos2(pr.max.x, anchor.y), pos2(x0, top + 10.0)], lead); - painter.rect_filled(card.translate(vec2(0.0, 1.0)), 4.0, t.page_shadow); - painter.rect_filled(card, 4.0, if selected { t.checked } else { Color32::WHITE }); - painter.rect_stroke(card, 4.0, Stroke::new(if selected { 1.5 } else { 1.0 }, color), egui::StrokeKind::Inside); - painter.rect_filled(Rect::from_min_size(card.min, vec2(4.0, h)), egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }, color); - painter.galley(pos2(card.min.x + 10.0, card.min.y + 6.0), head.clone(), t.text); - painter.galley(pos2(card.min.x + 10.0, card.min.y + 10.0 + head.size().y), body, t.text); - let r = ui.interact(card, ui.id().with(("balloon", *id)), Sense::click()); - if r.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - if r.clicked() { - clicked = Some(pos.clone()); - } + let card = if active { + active_balloon(app, ui, painter, *id, &thread, card, fs, color) + } else { + card_shapes(painter, &t, card, color, selected, None); + let mut y = card.min.y + 6.0; + for (i, (g, gap)) in lines.into_iter().enumerate() { + y += gap; + if i >= 2 && i % 2 == 0 { + // A reply starts: a hairline between it and what it answers. + painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border)); + } + let h = g.size().y; + painter.galley(pos2(card.min.x + 10.0, y), g, t.text); + y += h; + } + let r = ui.interact(card, ui.id().with(("balloon", *id)), Sense::click()); + if r.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if r.clicked() { + clicked = Some((*id, pos.clone())); + } + card + }; + next_y = card.max.y + 6.0; + app.canvas.balloon_rects.push((*id, card)); } } - if let Some(p) = clicked { + if let Some((id, p)) = clicked { + select_balloon(app, ui.ctx(), id); app.session.sel = wordcraft_engine::Selection::caret(p); - app.canvas.want_focus = true; } } +fn author_name(a: &str) -> String { + if a.is_empty() { tl!("Author").to_string() } else { a.to_string() } +} + +/// A balloon card's background, border and author-colour stripe; `slot` paints them under +/// widgets added before the card's size was known. +fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32, selected: bool, slot: Option) { + let shapes = vec![ + egui::Shape::rect_filled(card.translate(vec2(0.0, 1.0)), 4.0, t.page_shadow), + egui::Shape::rect_filled(card, 4.0, if selected { t.checked } else { Color32::WHITE }), + egui::Shape::rect_stroke(card, 4.0, Stroke::new(if selected { 1.5 } else { 1.0 }, color), egui::StrokeKind::Inside), + egui::Shape::rect_filled(Rect::from_min_size(card.min, vec2(4.0, card.height())), egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }, color), + ]; + match slot { + Some(i) => painter.set(i, egui::Shape::Vec(shapes)), + None => { + painter.extend(shapes); + } + } +} + +/// The selected balloon: the comment and its replies as editable fields (each edit is one undo +/// step via `review.editComment`), an optional reply field, and Reply / Resolve / Delete. +/// `card` carries last frame's size; returns this frame's. +#[allow(clippy::too_many_arguments)] +fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u32, thread: &[u32], card: Rect, fs: f32, color: Color32) -> Rect { + let t = Tokens::get(ui.ctx()); + let slot = painter.add(egui::Shape::Noop); + // The card's own click area sits under its fields, so a click on its padding stays here. + let _ = ui.interact(card, ui.id().with(("balloon", id)), Sense::click()); + let inner = Rect::from_min_max(pos2(card.min.x + 10.0, card.min.y + 6.0), pos2(card.max.x - 6.0, card.min.y + 20_000.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min))); + child.spacing_mut().item_spacing.y = 3.0; + let (font, bold, small) = (regular(fs), semibold(fs), regular((fs * 0.9).max(8.0))); + let focus = std::mem::take(&mut app.canvas.balloon_focus); + let name = |app: &WordApp, cid: u32| app.session.doc.comments.get(&cid).map(|c| author_name(&c.author)).unwrap_or_default(); + let resolved = app.session.doc.comments.get(&id).is_some_and(|c| c.resolved); + child.horizontal(|ui| { + ui.label(egui::RichText::new(name(app, id)).font(bold.clone()).color(t.text)); + if resolved { + ui.label(egui::RichText::new(tl!("Resolved")).font(small.clone()).color(t.green)); + } + }); + crate::panes::comment_editor(app, &mut child, id, Some(font.clone()), focus && !app.canvas.balloon_reply); + for rid in thread { + child.add_space(4.0); + child.label(egui::RichText::new(name(app, *rid)).font(bold.clone()).color(t.text)); + crate::panes::comment_editor(app, &mut child, *rid, Some(font.clone()), false); + } + if app.canvas.balloon_reply && crate::panes::reply_editor(app, &mut child, id, Some(font.clone()), focus) { + app.canvas.balloon_reply = false; + } + let (mut reply, mut resolve, mut delete) = (false, false, false); + child.add_space(2.0); + child.horizontal(|ui| { + let button = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).font(small.clone())).small()).clicked(); + reply = button(ui, tl!("Reply")); + resolve = button(ui, if resolved { tl!("Reopen") } else { tl!("Resolve") }); + delete = button(ui, tl!("Delete")); + }); + let card = Rect::from_min_max(card.min, pos2(card.max.x, child.min_rect().max.y + 6.0)); + app.canvas.balloon_h = card.height(); + card_shapes(painter, &t, card, color, true, Some(slot)); + if reply { + app.canvas.balloon_reply = true; + app.canvas.balloon_focus = true; + } + if resolve { + let _ = app.run("review.resolveComment", json!({"id": id})); + } + if delete { + deselect_balloon(app, ui.ctx()); + let _ = app.run("review.deleteComment", json!({"id": id})); + } + card +} + +/// Select a comment's balloon (its text gets the keyboard), writing back any edit in the one +/// selected before. +pub fn select_balloon(app: &mut WordApp, ctx: &egui::Context, id: u32) { + if app.canvas.balloon == Some(id) { + return; + } + deselect_balloon(app, ctx); + app.canvas.balloon = Some(id); + app.canvas.balloon_focus = true; + app.canvas.balloon_h = 0.0; +} + +/// Leave the selected balloon, writing back what was typed in it (its fields may not be drawn +/// again to notice they lost focus). +pub fn deselect_balloon(app: &mut WordApp, ctx: &egui::Context) { + let Some(id) = app.canvas.balloon.take() else { return }; + app.canvas.balloon_reply = false; + app.canvas.balloon_focus = false; + let thread: Vec = + std::iter::once(id).chain(app.session.doc.comments.iter().filter(|(_, c)| c.parent == Some(id)).map(|(k, _)| *k)).collect(); + for c in thread { + crate::panes::commit_comment(app, ctx, c); + } + crate::panes::commit_reply(app, ctx, id); +} + fn dashed(p: &egui::Painter, a: Pos2, b: Pos2, s: Stroke) { p.extend(egui::Shape::dashed_line(&[a, b], s, 4.0, 3.0)); } @@ -986,6 +1157,15 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo break; } } + // Margins: the edges of the white text area drag the section's margins. Registered before + // the indent markers, so a marker sitting on an edge keeps the pointer where they overlap. + let (le, re) = (x0 + page.body.x * scale, x0 + page.body.right() * scale); + for (edge, zone) in [ + (MarginEdge::Left, Rect::from_min_max(pos2(le - 10.0, bar.min.y), pos2(le + 3.0, bar.max.y))), + (MarginEdge::Right, Rect::from_min_max(pos2(re - 3.0, bar.min.y), pos2(re + 10.0, bar.max.y))), + ] { + margin_handle(app, ui, edge, zone, |p| (p.x - x0) / scale, §); + } // Indent markers for the caret's paragraph (relative to its column). let col_x = page.body.x; if let Some(para) = app.session.doc.para_at(&app.session.sel.focus) { @@ -1070,7 +1250,12 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo let y0 = pr.min.y; let vbar = Rect::from_min_max(pos2(v.min.x + 4.0, y0), pos2(v.max.x - 3.0, y0 + page.h.min(20_000.0) * scale)); vp.rect_filled(vbar, 0.0, t.ruler_margin); - vp.rect_filled(Rect::from_min_max(pos2(vbar.min.x, y0 + page.body.y * scale), pos2(vbar.max.x, y0 + page.body.bottom() * scale)), 0.0, t.ruler); + let (te, be) = (y0 + page.body.y * scale, y0 + page.body.bottom() * scale); + vp.rect_filled(Rect::from_min_max(pos2(vbar.min.x, te), pos2(vbar.max.x, be)), 0.0, t.ruler); + for (edge, ey) in [(MarginEdge::Top, te), (MarginEdge::Bottom, be)] { + let zone = Rect::from_min_max(pos2(vbar.min.x, ey - 4.0), pos2(vbar.max.x, ey + 4.0)); + margin_handle(app, ui, edge, zone, |p| (p.y - y0) / scale, §); + } let origin = page.body.y; let mut k = -((origin / unit).ceil() as i32) * 8; loop { @@ -1089,7 +1274,71 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo break; } } - let _ = sect; +} + +/// One edge of a page's text area on a ruler. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum MarginEdge { + Left, + Right, + Top, + Bottom, +} + +impl MarginEdge { + fn param(self) -> &'static str { + match self { + MarginEdge::Left => "left", + MarginEdge::Right => "right", + MarginEdge::Top => "top", + MarginEdge::Bottom => "bottom", + } + } + fn label(self) -> &'static str { + match self { + MarginEdge::Left => "Left Margin", + MarginEdge::Right => "Right Margin", + MarginEdge::Top => "Top Margin", + MarginEdge::Bottom => "Bottom Margin", + } + } +} + +/// The least text width (or height) a margin drag leaves: one inch, in points. +const MIN_TEXT: f32 = 72.0; + +/// The margin (points) after dragging `edge` of the text area on a ruler to `at` points from the +/// page's left (or top) edge: snapped to 1/16 inch, never negative, and leaving at least an inch +/// of text between it and the opposite margin. +pub(crate) fn dragged_margin(edge: MarginEdge, at: f32, s: &wordcraft_doc::section::SectionProps) -> f32 { + let (raw, room, cur) = match edge { + MarginEdge::Left => (at - s.gutter, s.page_w - s.margin_right - s.gutter - MIN_TEXT, s.margin_left), + MarginEdge::Right => (s.page_w - at, s.page_w - s.margin_left - s.gutter - MIN_TEXT, s.margin_right), + MarginEdge::Top => (at, s.page_h - s.margin_bottom - MIN_TEXT, s.margin_top), + MarginEdge::Bottom => (s.page_h - at, s.page_h - s.margin_top - MIN_TEXT, s.margin_bottom), + }; + if !raw.is_finite() || !room.is_finite() { + return cur; + } + ((raw / 4.5).round() * 4.5).clamp(0.0, room.max(0.0)) +} + +/// A draggable margin edge on a ruler: `to_pt` turns the pointer into points from the page's +/// left (or top) edge. A whole drag is one undo step, through `layout.margins`. +fn margin_handle(app: &mut WordApp, ui: &Ui, edge: MarginEdge, zone: Rect, to_pt: impl Fn(Pos2) -> f32, sect: &wordcraft_doc::section::SectionProps) { + let cursor = + if matches!(edge, MarginEdge::Left | MarginEdge::Right) { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }; + let r = ui.interact(zone, ui.id().with(("ruler_margin", edge)), Sense::drag()).on_hover_cursor(cursor); + let r = if r.dragged() { r } else { r.on_hover_text(tl!(edge.label())) }; + if r.dragged() + && let Some(pp) = r.interact_pointer_pos() + { + let v = dragged_margin(edge, to_pt(pp), sect); + if !r.drag_started() { + app.session.join_next_undo(); + } + let _ = app.run("layout.margins", json!({ edge.param(): v })); + } } /// Convert a page position (points) to screen coordinates (for agents and tests). @@ -1302,6 +1551,90 @@ fn is_followable_link(url: &str) -> bool { mod tests { use super::*; + /// #167: dragging a ruler's margin edge sets that margin, snapped, never negative, and + /// always leaving an inch of text. + #[test] + fn margin_drags_are_clamped() { + let s = wordcraft_doc::section::SectionProps { + page_w: 612.0, + page_h: 792.0, + margin_left: 72.0, + margin_right: 72.0, + margin_top: 72.0, + margin_bottom: 72.0, + gutter: 0.0, + ..Default::default() + }; + assert_eq!(dragged_margin(MarginEdge::Left, 100.0, &s), 99.0, "snapped to 1/16 inch"); + assert_eq!(dragged_margin(MarginEdge::Right, 612.0 - 36.0, &s), 36.0); + assert_eq!(dragged_margin(MarginEdge::Left, -50.0, &s), 0.0, "never negative"); + assert_eq!(dragged_margin(MarginEdge::Left, 600.0, &s), 612.0 - 72.0 - 72.0, "an inch of text is left"); + assert_eq!(dragged_margin(MarginEdge::Right, 0.0, &s), 612.0 - 72.0 - 72.0); + assert_eq!(dragged_margin(MarginEdge::Top, 9.0, &s), 9.0); + assert_eq!(dragged_margin(MarginEdge::Bottom, 100.0, &s), 792.0 - 72.0 - 72.0); + let g = wordcraft_doc::section::SectionProps { gutter: 36.0, ..s.clone() }; + assert_eq!(dragged_margin(MarginEdge::Left, 144.0, &g), 108.0, "the gutter isn't margin"); + assert_eq!(dragged_margin(MarginEdge::Left, f32::NAN, &s), 72.0, "junk keeps the margin"); + + // Through the command: the result is applied and is one undo step. + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + let sect = wordcraft_engine::cmd::page::sect(&app.session); + let v = dragged_margin(MarginEdge::Left, sect.page_w, §); + app.run("layout.margins", json!({"left": v})).unwrap(); + let after = wordcraft_engine::cmd::page::sect(&app.session); + assert!(after.page_w - after.margin_left - after.margin_right - after.gutter >= MIN_TEXT - 0.01); + assert!(app.session.undo()); + assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).margin_left, sect.margin_left); + } + + /// #167: a comment balloon edits in place like Word's: click it, type, leave it, and the + /// comment has the new text as one undo step. + #[test] + fn a_balloon_edits_its_comment() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + a.run("text.insert", json!({"text": "Some text here"})).unwrap(); + a.run("select.text", json!({"text": "text"})).unwrap(); + let id = a.run("review.newComment", json!({"text": "Original"})).unwrap()["id"].as_u64().unwrap() as u32; + a.run("view.commentsPane", json!({"value": false})).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, + ); + for _ in 0..6 { + h.step(); + } + let card = h.state().canvas.balloon_rects.iter().find(|(i, _)| *i == id).map(|(_, r)| *r).expect("the comment has a balloon"); + let at = card.left_top() + vec2(30.0, 8.0); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + for _ in 0..3 { + h.step(); + } + assert_eq!(h.state().canvas.balloon, Some(id), "the click selected the balloon"); + let depth = h.state().session.undo_depth(); + for piece in ["Edited", " twice"] { + h.event(egui::Event::Text(piece.into())); + h.step(); + } + assert_eq!(h.state().session.undo_depth(), depth, "nothing is written while typing"); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + for _ in 0..3 { + h.step(); + } + let part = h.state().session.doc.comments[&id].part; + let text = h.state().session.doc.plain_text(StoryRef::Part(part)); + assert!(text.contains("Edited twice") && text.contains("Original"), "{text:?}"); + assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step for the edit"); + assert!(h.state_mut().session.undo()); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Part(part)), "Original"); + } + #[test] fn only_web_and_email_links_are_followed() { for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index df1877fe2..a9c171b05 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -54,6 +54,8 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { "pages": app.canvas.page_rects.iter().map(|r| [r.left(), r.top(), r.width(), r.height()]).collect::>(), "scale": app.canvas.scale, "caret": caret, + "balloons": app.canvas.balloon_rects.iter().map(|(id, r)| json!({"id": id, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect::>(), + "balloon": app.canvas.balloon, "focused": app.canvas.focused, "title": app.title_stem(), "dirty": app.session.dirty, diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 26327b7b9..66f20a0df 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -86,6 +86,7 @@ Border\nPainter Pincel de\nbordes Borders Bordes Bottom Border Borde inferior + Bottom Margin Margen inferior Bottom of Page Parte inferior de la página Bottom: Inferior: Box Cuadro @@ -224,6 +225,7 @@ Drawing\nCanvas Lienzo de\ndibujo Drop Cap Letra capital Drops all formatting and pastes plain text. Quita todo el formato y pega texto sin formato. + Edit Comment Editar comentario Edit Footer Editar pie de página Edit Header Editar encabezado Edit Recipient List Editar lista de destinatarios @@ -375,6 +377,7 @@ Layout Summary Resumen de disposición Left Izquierda Left Border Borde izquierdo + Left Margin Margen izquierdo Left: Izquierda: Letter Carta Line Línea @@ -566,6 +569,7 @@ Remove Space After Paragraph Quitar espacio después del párrafo Remove Table of Contents Quitar tabla de contenido Remove Watermark Quitar marca de agua + Reopen Reabrir Repeat Repetir Repeat Header Rows Repetir filas de encabezado Replace Reemplazar @@ -596,6 +600,7 @@ Rich Text Format (RTF) Formato de texto enriquecido (RTF) Right Derecha Right Border Borde derecho + Right Margin Margen derecho Right-to-Left Text Direction Dirección de texto de derecha a izquierda Left-to-Right Text Direction Dirección de texto de izquierda a derecha Right: Derecha: @@ -744,6 +749,7 @@ Three Tres Tight Ajustado Top Border Borde superior + Top Margin Margen superior Top of Page Parte superior de la página Top: Superior: Total Row Fila de totales @@ -804,6 +810,7 @@ Wrap Text Ajustar texto Wrap\nText Ajustar\ntexto Write & Insert Fields Escribir e insertar campos + Write a reply Escribir una respuesta Zoom Zoom Zoom In Acercar Zoom Out Alejar diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 73a00c8c9..66a05e766 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -52,9 +52,14 @@ AutoSave 自動保存 AutoSave documents you have saved in WordCraft WordCraft で保存した文書を自動保存する AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動保存できませんでした: {error}。文書を保存し直すと、自動保存が再開します。 + Bottom Margin 下余白 Dark page (white text on black) ダーク ページ (黒地に白い文字) + Edit Comment コメントを編集 + Left Margin 左余白 More templates → その他のテンプレート → No templates match your search. 検索に一致するテンプレートはありません。 + Reopen 再度開く + Right Margin 右余白 Search for templates テンプレートを検索 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 表示 › モードの切り替えと同様に、文書の色を反転して表示します。保存、印刷、PDF は変わりません。 AutoSave saves every change to the file (needs a saved document) 自動保存は変更のたびにファイルへ保存します(保存済みの文書が必要です) @@ -719,6 +724,7 @@ Three 3 段 Tight 狭い Top Border 上罫線 + Top Margin 上余白 Top of Page ページの上部 Top: 上: Total Row 集計行 @@ -778,6 +784,7 @@ Wrap Text 文字列の折り返し Wrap\nText 文字列の折り返し Write & Insert Fields 文章入力とフィールドの挿入 + Write a reply 返信を入力 Zoom ズーム Zoom In 拡大 Zoom Out 縮小 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4db082c14..43d5f5457 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -81,6 +81,7 @@ Border\nPainter Pincel\nde bordas Borders Bordas Bottom Border Borda inferior + Bottom Margin Margem inferior Bottom of Page Fim da página Bottom: Inferior: Box Caixa @@ -179,8 +180,12 @@ Delete Style Excluir estilo Delete Table Excluir tabela Design Design + Edit Comment Editar comentário + Left Margin Margem esquerda More templates → Mais modelos → No templates match your search. Nenhum modelo corresponde à pesquisa. + Reopen Reabrir + Right Margin Margem direita Search for templates Pesquisar modelos development build versão de desenvolvimento Diagonal Diagonal @@ -719,6 +724,7 @@ Three Três Tight Estreito Top Border Borda superior + Top Margin Margem superior Top of Page Topo da página Top: Superior: Total Row Linha de totais @@ -778,6 +784,7 @@ Wrap Text Quebrar texto Wrap\nText Quebrar\ntexto Write & Insert Fields Escrever e inserir campos + Write a reply Escrever uma resposta Zoom Zoom Zoom In Ampliar Zoom Out Reduzir diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index f817466db..85bb10166 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -63,7 +63,12 @@ AutoSave Automatsko čuvanje AutoSave documents you have saved in WordCraft Automatski čuvaj dokumente koje ste već sačuvali u WordCraft-u AutoSave failed: {error}. Save the document to turn AutoSave back on. Automatsko čuvanje neuspelo: {error}. Sačuvajte dokument da biste ga ponovo uključili. + Bottom Margin Donja margina Dark page (white text on black) Tamna stranica (beo tekst na crnom) + Edit Comment Uredi komentar + Left Margin Leva margina + Reopen Ponovo otvori + Right Margin Desna margina Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Prikaži dokumente sa invertovanim bojama, kao kod Prikaz › Promeni režim. Čuvanje, štampanje i PDF nisu pogođeni. AutoSave saves every change to the file (needs a saved document) Automatsko čuvanje upisuje svaku izmenu u fajl (dokument mora biti već sačuvan) AutoText Automatski tekst @@ -727,6 +732,7 @@ Three Tri Tight Tesno Top Border Gornja ivica + Top Margin Gornja margina Top of Page Vrh strane Top: Gore: Total Row Red ukupno @@ -786,6 +792,7 @@ Wrap Text Prelomi tekst Wrap\nText Prelomi\ntekst Write & Insert Fields Pisanje i umetanje polja + Write a reply Napišite odgovor Zoom Zumiranje Zoom In Uvećaj Zoom Out Umanji diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index d493b1453..e36072c41 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -68,7 +68,12 @@ AutoSave Аутоматско чување AutoSave documents you have saved in WordCraft Аутоматски чувај документе које сте већ сачували у WordCraft-у AutoSave failed: {error}. Save the document to turn AutoSave back on. Аутоматско чување неуспело: {error}. Сачувајте документ да бисте га поново укључили. + Bottom Margin Доња маргина Dark page (white text on black) Тамна страница (бео текст на црном) + Edit Comment Уреди коментар + Left Margin Лева маргина + Reopen Поново отвори + Right Margin Десна маргина Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Прикажи документе са инвертованим бојама, као код Приказ › Промени режим. Чување, штампање и PDF нису погођени. AutoSave saves every change to the file (needs a saved document) Аутоматско чување уписује сваку измену у фајл (документ мора бити већ сачуван) AutoText Аутоматски текст @@ -732,6 +737,7 @@ Three Три Tight Тесно Top Border Горња ивица + Top Margin Горња маргина Top of Page Врх стране Top: Горе: Total Row Ред укупно @@ -791,6 +797,7 @@ Wrap Text Преломи текст Wrap\nText Преломи\nтекст Write & Insert Fields Писање и уметање поља + Write a reply Напишите одговор Zoom Зумирање Zoom In Увећај Zoom Out Умањи diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3f48f086f..8e80d1c3a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -83,6 +83,7 @@ Border\nPainter Пензель\nмеж Borders Межі Bottom Border Нижня межа + Bottom Margin Нижнє поле Bottom of Page Унизу сторінки Bottom: Знизу: Box Рамка @@ -187,8 +188,12 @@ Delete Style Видалити стиль Delete Table Видалити таблицю Design Дизайн + Edit Comment Редагувати коментар + Left Margin Ліве поле More templates → Більше шаблонів → No templates match your search. Немає шаблонів, що відповідають пошуку. + Reopen Відкрити знову + Right Margin Праве поле Search for templates Пошук шаблонів development build Збірка для розробки Diagonal Діагональ @@ -745,6 +750,7 @@ Three Три Tight Щільний Top Border Верхня межа + Top Margin Верхнє поле Top of Page Угорі сторінки Top: Зверху: Total Row Рядок підсумків @@ -805,6 +811,7 @@ Wrap Text Обтікання текстом Wrap\nText Обтікання\nтекстом Write & Insert Fields Написання й вставлення полів + Write a reply Напишіть відповідь Zoom Масштаб Zoom In Збільшити масштаб Zoom Out Зменшити масштаб diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 45c921684..5af0775e4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -63,9 +63,14 @@ AutoSave 自动保存 AutoSave documents you have saved in WordCraft 自动保存在 WordCraft 中保存过的文档 AutoSave failed: {error}. Save the document to turn AutoSave back on. 自动保存失败:{error}。重新保存文档后将恢复自动保存。 + Bottom Margin 下边距 Dark page (white text on black) 深色页面(黑底白字) + Edit Comment 编辑批注 + Left Margin 左边距 More templates → 更多模板 → No templates match your search. 没有与搜索匹配的模板。 + Reopen 重新打开 + Right Margin 右边距 Search for templates 搜索模板 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像“视图 › 切换模式”一样以反转的颜色显示文档。保存、打印和 PDF 不受影响。 AutoSave saves every change to the file (needs a saved document) 自动保存会把每次更改写入文件(文档需已保存) @@ -730,6 +735,7 @@ Three 三栏 Tight 紧密 Top Border 上框线 + Top Margin 上边距 Top of Page 页面顶端 Top: 上: Total Row 汇总行 @@ -789,6 +795,7 @@ Wrap Text 环绕文字 Wrap\nText 环绕文字 Write & Insert Fields 编写和插入域 + Write a reply 写回复 Zoom 缩放 Zoom In 放大 Zoom Out 缩小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9e482ca22..f92a86ae1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -53,9 +53,14 @@ AutoSave 自動儲存 AutoSave documents you have saved in WordCraft 自動儲存在 WordCraft 中儲存過的文件 AutoSave failed: {error}. Save the document to turn AutoSave back on. 自動儲存失敗:{error}。重新儲存文件後將恢復自動儲存。 + Bottom Margin 下邊界 Dark page (white text on black) 深色頁面(黑底白字) + Edit Comment 編輯註解 + Left Margin 左邊界 More templates → 更多範本 → No templates match your search. 沒有符合搜尋的範本。 + Reopen 重新開啟 + Right Margin 右邊界 Search for templates 搜尋範本 Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. 像「檢視 › 切換模式」一樣以反轉的色彩顯示文件。儲存、列印和 PDF 不受影響。 AutoSave saves every change to the file (needs a saved document) 自動儲存會將每次變更寫入檔案(文件需已儲存) @@ -720,6 +725,7 @@ Three 三欄 Tight 緊密 Top Border 上框線 + Top Margin 上邊界 Top of Page 頁面頂端 Top: 上: Total Row 合計列 @@ -779,6 +785,7 @@ Wrap Text 文繞圖 Wrap\nText 文繞圖 Write & Insert Fields 書寫與插入欄位 + Write a reply 撰寫回覆 Zoom 縮放 Zoom In 放大 Zoom Out 縮小 diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2390b42a3..cd19b5834 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -238,41 +238,119 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { ui.label(egui::RichText::new(tl!("Resolved")).small().color(t.green)); } }); - // Editable comment text (writes back to the comment's story). - let part = app.session.doc.comments.get(&(id as u32)).map(|x| x.part); - if let Some(part) = part { - let key = egui::Id::new(("comment_edit", id)); - let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part))); - let r = ui.add(egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text(tl!("Add a comment"))); - if r.changed() { - ui.data_mut(|d| d.insert_temp(key, text.clone())); - } - if r.lost_focus() { - let blocks = - text.split('\n').map(|l| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(l, Default::default()))).collect(); - let _ = app.session.doc.set_story(StoryRef::Part(part), blocks); - app.session.touch(); - ui.data_mut(|d| d.remove::(key)); - } - } + // Editable comment text (one undo step per edit, like the balloons). + comment_editor(app, ui, id as u32, None, false); + let open_key = egui::Id::new(("comment_reply_open", id)); + let mut replying = ui.data(|d| d.get_temp::(open_key)).unwrap_or(false); ui.horizontal(|ui| { if ui.small_button(tl!("Go to")).clicked() && let Some(a) = c.get("anchor").filter(|a| !a.is_null()) { let _ = app.run("caret.set", json!({"pos": a})); } - if ui.small_button(tl!("Resolve")).clicked() { + if ui.small_button(tl!("Reply")).clicked() { + replying = true; + ui.data_mut(|d| d.insert_temp(open_key, true)); + ui.data_mut(|d| d.insert_temp(egui::Id::new(("comment_reply_focus", id)), true)); + } + let resolved = c.get("resolved").and_then(Value::as_bool).unwrap_or(false); + if ui.small_button(if resolved { tl!("Reopen") } else { tl!("Resolve") }).clicked() { let _ = app.run("review.resolveComment", json!({"id": id})); } if ui.small_button(tl!("Delete")).clicked() { let _ = app.run("review.deleteComment", json!({"id": id})); } }); + if replying { + let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::new(("comment_reply_focus", id)))).unwrap_or(false); + if reply_editor(app, ui, id as u32, None, focus) { + ui.data_mut(|d| d.remove::(open_key)); + } + } }); ui.add_space(6.0); } }); - let _ = vec2(0.0, 0.0); +} + +fn edit_key(id: u32) -> egui::Id { + egui::Id::new(("comment_edit", id)) +} + +fn reply_key(id: u32) -> egui::Id { + egui::Id::new(("comment_reply", id)) +} + +/// A comment's text, editable in place (the Comments pane and the balloons). Typing stays in a +/// draft until the field loses focus; then [`commit_comment`] writes it back with one +/// `review.editComment`, so each editing session is one undo step. +pub(crate) fn comment_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option, focus: bool) { + let Some(part) = app.session.doc.comments.get(&id).map(|c| c.part) else { return }; + let key = edit_key(id); + let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part))); + let mut edit = egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text(tl!("Add a comment")); + if let Some(f) = font { + edit = edit.font(f); + } + let r = ui.add(edit); + if focus { + r.request_focus(); + } + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, text)); + } + if r.lost_focus() { + commit_comment(app, ui.ctx(), id); + } +} + +/// Write a comment's draft text back, when it has one that differs from the comment. +pub(crate) fn commit_comment(app: &mut WordApp, ctx: &egui::Context, id: u32) { + let Some(text) = ctx.data_mut(|d| d.remove_temp::(edit_key(id))) else { return }; + let Some(part) = app.session.doc.comments.get(&id).map(|c| c.part) else { return }; + if text != app.session.doc.plain_text(StoryRef::Part(part)) { + let _ = app.run("review.editComment", json!({"id": id, "text": text})); + } +} + +/// The comment a reply to `id` answers: replies to a reply join its thread. +fn thread_root(app: &WordApp, id: u32) -> u32 { + match app.session.doc.comments.get(&id).and_then(|c| c.parent) { + Some(p) if app.session.doc.comments.contains_key(&p) => p, + _ => id, + } +} + +/// A field for replying to a comment. When it loses focus a non-empty reply is posted with +/// `review.reply`; returns true when the field is done (posted or left empty). +pub(crate) fn reply_editor(app: &mut WordApp, ui: &mut Ui, id: u32, font: Option, focus: bool) -> bool { + let key = reply_key(id); + let mut text = ui.data(|d| d.get_temp::(key)).unwrap_or_default(); + let mut edit = egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text(tl!("Write a reply")); + if let Some(f) = font { + edit = edit.font(f); + } + let r = ui.add(edit); + if focus { + r.request_focus(); + } + if r.changed() { + ui.data_mut(|d| d.insert_temp(key, text)); + } + if r.lost_focus() { + commit_reply(app, ui.ctx(), id); + return true; + } + false +} + +/// Post a pending reply draft to comment `id` (nothing when there is none or it is blank). +pub(crate) fn commit_reply(app: &mut WordApp, ctx: &egui::Context, id: u32) { + let Some(text) = ctx.data_mut(|d| d.remove_temp::(reply_key(id))) else { return }; + if !text.trim().is_empty() { + let root = thread_root(app, id); + let _ = app.run("review.reply", json!({"id": root, "text": text.trim_end()})); + } } /// Style Inspector: the paragraph and character levels at the caret, each with its style and the diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 27aea7a98..fcb80206d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -53,7 +53,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | -| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, perf | +| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | | `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates | | `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page | | `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag |