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 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-10 14:38:06 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 88ad402581
commit 4a75c57f58
15 changed files with 542 additions and 51 deletions
+4 -1
View File
@@ -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);
+11
View File
@@ -11,6 +11,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<u32> {
let f = &s.sel.focus;
+8
View File
@@ -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);
+363 -30
View File
@@ -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<u32>,
/// 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<u32, Vec<u32>> = 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<usize, Vec<(f32, f32, u32, Pos)>> = 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<String> = Vec::new();
let mut clicked: Option<Pos> = 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<usize> = 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<u32> = 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<egui::Galley>, 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::<f32>() + 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<egui::layers::ShapeIdx>) {
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<u32> =
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, &sect);
}
// 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, &sect);
}
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, &sect);
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"] {
+2
View File
@@ -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::<Vec<_>>(),
"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::<Vec<_>>(),
"balloon": app.canvas.balloon,
"focused": app.canvas.focused,
"title": app.title_stem(),
"dirty": app.session.dirty,
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+7
View File
@@ -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 縮小
Can't render this file because it contains an unexpected character in line 416 and column 13.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 25.
+7
View File
@@ -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 Умањи
Can't render this file because it contains an unexpected character in line 11 and column 25.
+7
View File
@@ -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 Зменшити масштаб
Can't render this file because it contains an unexpected character in line 6 and column 31.
+7
View File
@@ -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 缩小
Can't render this file because it contains an unexpected character in line 6 and column 25.
+7
View File
@@ -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 縮小
Can't render this file because it contains an unexpected character in line 417 and column 13.
+97 -19
View File
@@ -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::<String>(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::<String>(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::<bool>(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::<bool>(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::<bool>(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<egui::FontId>, 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::<String>(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::<String>(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<egui::FontId>, focus: bool) -> bool {
let key = reply_key(id);
let mut text = ui.data(|d| d.get_temp::<String>(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::<String>(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
+1 -1
View File
@@ -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 |