styles.updateToMatch copied only the direct run/paragraph formatting at the caret onto the style, so text whose look came from its own style (Normal, Title, ...) changed nothing; it then wiped that paragraph's direct formatting without applying the style, so the selection lost its look too. Now the style takes the selection's effective formatting (paragraph style chain, character style chain, direct formatting) as a diff against what the style currently gives, keeping its outline level and numbering. The selected paragraphs take the style and drop only the direct formatting it now carries. The Home styles gallery tiles get the right-click menu (Update to Match Selection, Modify...). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
7d1f6a07c1
commit
46d5e99b3e
|
|
@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
|||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::{Block, Pos};
|
||||
use wordcraft_doc::{Block, CharProps, Pos};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
|
@ -854,8 +854,7 @@ fn create_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
|
||||
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = style_id(s, p::req_str(v, "style")?)?;
|
||||
let chr: Option<wordcraft_doc::CharProps> =
|
||||
v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let chr: Option<CharProps> = v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let para: Option<ParaProps> =
|
||||
v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let next = p::str(v, "next").map(str::to_string);
|
||||
|
|
@ -878,36 +877,108 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
|||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Update Style to Match Selection: the style takes on how the selection looks — its paragraph
|
||||
/// style, character style and direct formatting, read at the start of the selection — so every
|
||||
/// paragraph using the style changes. A paragraph style is then applied to the selected
|
||||
/// paragraphs, whose direct formatting the style now carries is dropped (other direct formatting,
|
||||
/// a bold word say, stays). The style keeps its outline level and list numbering.
|
||||
fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f).cloned() else { return sel_result(s) };
|
||||
let (a, b) = s.sel.ordered();
|
||||
let Some(para) = s.doc.para_at(&a).cloned() else { return sel_result(s) };
|
||||
let id = match p::str(v, "style") {
|
||||
Some(n) => style_id(s, n)?,
|
||||
None => para.props.style.clone().unwrap_or_else(|| "Normal".into()),
|
||||
};
|
||||
let mut chr = para.props_at(f.off).clone();
|
||||
chr.style = None;
|
||||
chr.link = None;
|
||||
chr.ins = None;
|
||||
chr.del = None;
|
||||
chr.fmt_change = None;
|
||||
let mut pp = para.props.formatting();
|
||||
pp.style = None;
|
||||
pp.numbering = None;
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
st.chr.overlay(&chr);
|
||||
st.para.overlay(&pp);
|
||||
let sheet = &s.doc.styles;
|
||||
let Some(target) = sheet.get(&id).cloned() else { return Err(CmdError::Params(format!("no style `{id}`"))) };
|
||||
if !matches!(target.kind, StyleKind::Paragraph | StyleKind::Character) {
|
||||
return Err(CmdError::Failed(format!("`{}` can't be updated to match the selection", target.name)));
|
||||
}
|
||||
// Remove the now-redundant direct formatting from the paragraph.
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
let len = p.len();
|
||||
p.format(0, len, &|c| *c = c.cleared())?;
|
||||
let style = p.props.style.clone();
|
||||
p.props = ParaProps { style, numbering: p.props.numbering, ..Default::default() };
|
||||
let direct = if s.sel.is_collapsed() { para.props_at(a.off) } else { para.props_of_char(a.off) }.clone();
|
||||
let para_style = para.props.style.clone().unwrap_or_else(|| "Normal".into());
|
||||
// How the selection looks: document defaults, its paragraph style chain, its character
|
||||
// style chain, direct formatting (the resolve order, before defaults are filled in).
|
||||
let (mut want_p, mut want_c) = sheet.para_style_props(¶_style);
|
||||
let char_chain = |c: &mut CharProps, style: Option<&str>| {
|
||||
for st in style.map(|cs| sheet.chain(cs)).unwrap_or_default() {
|
||||
if matches!(st.kind, StyleKind::Character | StyleKind::Paragraph) {
|
||||
c.overlay(&st.chr);
|
||||
}
|
||||
}
|
||||
};
|
||||
char_chain(&mut want_c, direct.style.as_deref());
|
||||
want_c.overlay(&CharProps { style: None, ..direct.formatting() });
|
||||
want_p.overlay(&ParaProps { style: None, numbering: None, ..para.props.formatting() });
|
||||
// How the style looks now, in the same terms.
|
||||
let (have_p, have_c) = match target.kind {
|
||||
StyleKind::Character => {
|
||||
let (pp, mut cc) = sheet.para_style_props(¶_style);
|
||||
char_chain(&mut cc, Some(&id));
|
||||
(pp, cc)
|
||||
}
|
||||
_ => sheet.para_style_props(&id),
|
||||
};
|
||||
let chr = retarget(&target.chr, &have_c, &want_c, CHAR_KEEP)?;
|
||||
let pp = if target.kind == StyleKind::Paragraph { retarget(&target.para, &have_p, &want_p, PARA_KEEP)? } else { target.para.clone() };
|
||||
if let Some(st) = s.doc.styles.get_mut(&id) {
|
||||
st.chr = chr;
|
||||
st.para = pp;
|
||||
}
|
||||
if target.kind == StyleKind::Paragraph {
|
||||
// The selected paragraphs take the style; what it now gives them needn't stay direct.
|
||||
let paths = s.doc.paths_between(&a, &b);
|
||||
for path in paths {
|
||||
let p = s.doc.para_mut(a.story, &path)?;
|
||||
p.props.style = Some(id.clone());
|
||||
p.props = strip_matching(&p.props, &want_p, PARA_KEEP);
|
||||
let len = p.len();
|
||||
p.format(0, len, &|c| *c = strip_matching(c, &want_c, CHAR_KEEP))?;
|
||||
p.mark = strip_matching(&p.mark, &want_c, CHAR_KEEP);
|
||||
}
|
||||
}
|
||||
s.pending = None;
|
||||
touch_all(s);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Character properties Update to Match leaves alone: the character style, links and revisions.
|
||||
const CHAR_KEEP: &[&str] = &["style", "link", "ins", "del", "fmtChange"];
|
||||
/// Paragraph properties Update to Match leaves alone: the style, its outline level (a heading
|
||||
/// stays a heading), list numbering and revisions.
|
||||
const PARA_KEEP: &[&str] = &["style", "outlineLevel", "numbering", "numChange", "fmtChange"];
|
||||
|
||||
fn props_map<T: serde::Serialize>(t: &T) -> serde_json::Map<String, Value> {
|
||||
match serde_json::to_value(t) {
|
||||
Ok(Value::Object(m)) => m,
|
||||
_ => serde_json::Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A style's own properties `own`, changed so that its chain gives `want` where it gave `have`:
|
||||
/// a property that differs is set to the wanted value, or dropped (inherited again) when the
|
||||
/// selection doesn't set it at all.
|
||||
fn retarget<T: serde::Serialize + serde::de::DeserializeOwned>(own: &T, have: &T, want: &T, keep: &[&str]) -> Result<T, CmdError> {
|
||||
let (mut out, have, want) = (props_map(own), props_map(have), props_map(want));
|
||||
let keys: std::collections::BTreeSet<&String> = have.keys().chain(want.keys()).collect();
|
||||
for k in keys {
|
||||
if keep.contains(&k.as_str()) || have.get(k) == want.get(k) {
|
||||
continue;
|
||||
}
|
||||
match want.get(k) {
|
||||
Some(v) => out.insert(k.clone(), v.clone()),
|
||||
None => out.remove(k),
|
||||
};
|
||||
}
|
||||
serde_json::from_value(Value::Object(out)).map_err(|e| CmdError::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
/// `direct` without the properties that have the same value in `inherited` (outside `keep`).
|
||||
fn strip_matching<T: serde::Serialize + serde::de::DeserializeOwned + Clone>(direct: &T, inherited: &T, keep: &[&str]) -> T {
|
||||
let (mut out, inherited) = (props_map(direct), props_map(inherited));
|
||||
out.retain(|k, v| keep.contains(&k.as_str()) || inherited.get(k) != Some(v));
|
||||
serde_json::from_value(Value::Object(out)).unwrap_or_else(|_| direct.clone())
|
||||
}
|
||||
|
||||
/// Delete a custom style. Text that uses it falls back to the style it was based on (or the
|
||||
/// default style), styles based on it are rebased, and a custom character style linked to it
|
||||
/// goes with it. Built-in styles are refused.
|
||||
|
|
|
|||
|
|
@ -2049,6 +2049,49 @@ fn styles_apply_multi_paragraph_selection() {
|
|||
}
|
||||
}
|
||||
|
||||
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
|
||||
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
|
||||
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
|
||||
#[test]
|
||||
fn update_heading_to_match_selection_takes_the_selections_formatting() {
|
||||
use wordcraft_doc::{Align, CharProps, ParaProps, Rgb, TextColor};
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Heading text"}));
|
||||
run(&mut s, "styles.apply", json!({"style": "Heading 2"}));
|
||||
run(&mut s, "text.newParagraph", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Body text"}));
|
||||
run(&mut s, "styles.apply", json!({"style": "Normal"}));
|
||||
run(&mut s, "select.text", json!({"text": "Body text"}));
|
||||
run(&mut s, "format.size", json!({"size": 20}));
|
||||
run(&mut s, "format.italic", json!({}));
|
||||
run(&mut s, "format.color", json!({"color": "C00000"}));
|
||||
run(&mut s, "para.alignCenter", json!({}));
|
||||
let normal = s.doc.styles.resolve_char(Some("Normal"), &CharProps::default());
|
||||
let before = s.undo_depth();
|
||||
|
||||
run(&mut s, "styles.updateToMatch", json!({"style": "Heading2"}));
|
||||
|
||||
let h2 = ParaProps { style: Some("Heading2".into()), ..Default::default() };
|
||||
let c = s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default());
|
||||
assert_eq!(c.size, 20.0);
|
||||
assert!(c.italic);
|
||||
assert_eq!(c.color, TextColor::Rgb(Rgb(0xC0, 0, 0)));
|
||||
assert_eq!(c.font, normal.font, "the heading font gives way to the selection's");
|
||||
let p = s.doc.styles.resolve_para(&h2);
|
||||
assert_eq!(p.align, Align::Center);
|
||||
assert_eq!(p.outline_level, Some(1), "Heading 2 stays a level-2 heading");
|
||||
// The selected paragraph now carries Heading 2, its direct formatting absorbed by the style.
|
||||
assert_eq!(para_style(&s, 1).as_deref(), Some("Heading2"));
|
||||
let body = s.doc.para_at(&Pos::body(1, 0)).unwrap();
|
||||
assert!(body.props_of_char(0).formatting().is_empty(), "{:?}", body.props_of_char(0));
|
||||
assert_eq!(body.props.align, None);
|
||||
assert_eq!(s.undo_depth(), before + 1, "one undo step");
|
||||
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.styles.resolve_char(Some("Heading2"), &CharProps::default()).size, 16.0);
|
||||
assert_eq!(para_style(&s, 1).as_deref(), Some("Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_apply_rejected_on_protected_document() {
|
||||
let mut s = s();
|
||||
|
|
|
|||
|
|
@ -339,6 +339,17 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
|
|||
let galley = ui.painter().layout_job(job);
|
||||
let label_pos = pos2(r.center().x - galley.size().x / 2.0, r.max.y - 9.0 - galley.size().y / 2.0);
|
||||
ui.painter().with_clip_rect(r).galley(label_pos, galley, t.text);
|
||||
// Right-click a tile: the same style actions as the Styles pane.
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(tl!("Update to Match Selection")).clicked() {
|
||||
let _ = app.run("styles.updateToMatch", json!({"style": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Modify…")).clicked() {
|
||||
app.dialog = crate::dialogs::Dialog::modify_style(app, id);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
let _ = app.run("para.style", json!({"style": id}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Update Style to Match Selection takes how the selection looks (its paragraph and character styles as well as direct formatting) and applies the style to it; Home › Styles gallery tiles get a right-click menu with Update to Match Selection and Modify… (#555) |
|
||||
| 2026-10-11 | trivial | Outlining tab in Outline view (level box, promote/demote, move, expand/collapse, Show Level, Show Text Formatting, Show First Line Only, Close); outline symbols select a heading with its content on click and expand/collapse on double-click; Alt+Shift+Plus/Minus, Alt+Shift+1–9, Alt+Shift+A (#493) |
|
||||
| 2026-10-11 | trivial | File › Options as panes (General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents), every setting wired; `file.options` headless (#485) |
|
||||
| 2026-10-11 | trivial | Right-click on a picture, shape or text box selects it and offers Wrap Text: the seven wrapping styles (current one checked), Move with Text and Fix Position on Page (`arrange.moveWithText`) (#513) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue