mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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:
co-authored by
Claude Opus 5.5
parent
88ad402581
commit
4a75c57f58
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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, §);
|
||||
}
|
||||
// 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"] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user