fix: keep per-level formatting when formatting layout/master placeholders

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-30 23:22:33 +01:00
co-authored by Claude Sonnet 5.5
parent 446bc528ad
commit adaa4e6fa1
5 changed files with 168 additions and 29 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
use serde_json::{Value, json};
use std::sync::Arc;
use super::text::{format_body, format_para, format_run};
use super::text::{format_body, format_para, format_run, format_run_levels};
use super::{
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text,
has_text_or_shapes, ids_param, ok, str_param,
+13 -5
View File
@@ -1,6 +1,6 @@
use super::{
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
};
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
@@ -72,10 +72,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
let _ = st;
let base = ctx_sizes;
format_run(s, &move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
})
// A layout/master level steps from its own effective size, not the first run's.
format_run_levels(
s,
&move |r| {
let cur = r.size.unwrap_or(base);
r.size = Some(step(cur, up));
},
&move |r, eff| {
let cur = r.size.or(eff.size).unwrap_or(18.0);
r.size = Some(step(cur, up));
},
)
}
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
+1 -1
View File
@@ -30,7 +30,7 @@ use editing::{edit, exit};
pub(crate) use editing::{insert_field, insert_text, paste_body};
pub use formatting::format_range;
use formatting::{fonts_used, replace_font};
pub(crate) use formatting::{format_body, format_para, format_run, link_text};
pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text};
pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines};
use ime::{commit, compose};
pub(crate) use layout::ctx_for;
+104 -22
View File
@@ -8,22 +8,34 @@ use super::{
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour),
/// or sets the formatting for what is typed next at a word boundary.
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
format_run_levels(s, f, &|r, _| f(r))
}
/// Like [`format_run`], with a separate change for the list-style levels of a layout or master
/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at
/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value.
pub(crate) fn format_run_levels(
s: &mut Session,
f: &(dyn Fn(&mut RunProps) + Send + Sync),
lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync),
) -> Result<Value> {
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = format_range(st, &t);
let collapsed = a == b;
// Only the levels of the paragraphs the selected range touches.
let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) };
edit_text(s, |body, _| {
if collapsed {
// Next typed text: put the props on an empty paragraph's end mark or keep pending.
if levels.is_empty() {
ed::format(body, a, b, f);
} else {
ed::format(body, a, b, f);
format_by_level(body, a, b, lf, &levels);
}
for (l, eff) in &levels {
let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default();
lf(&mut lvl.run, eff);
body.list_style.set(*l, lvl);
}
})?;
if collapsed {
// Remember as pending typing props by inserting nothing: handled via end mark for
// empty paragraphs; mid-paragraph carets keep surrounding formatting.
}
return ok();
}
let ids = st.selection.shapes.clone();
@@ -31,10 +43,21 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
let cells = st.selection.cells;
let on_master = editing_master(st);
// Effective run props of every level of the selected placeholders, resolved before editing.
let effective: Vec<(ShapeId, Vec<RunProps>)> = if editing_master(st) {
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
ids.iter()
.filter_map(|id| {
let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?;
let ctx = ctx.as_ref()?;
Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect()))
})
.collect()
} else {
Vec::new()
};
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id;
let inherited = on_master && sh.ph.is_some();
match &mut sh.kind {
ShapeKind::Table(t) => {
// Only the selected cells, when a range of them is selected.
@@ -49,11 +72,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
}
_ => {
let body = sh.text.get_or_insert_with(TextBody::default);
ed::format_all(body, f);
if inherited {
for l in 0..LIST_LEVELS {
let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e);
match eff {
Some(eff) => {
let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect();
format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels);
}
None => ed::format_all(body, f),
}
// The whole shape is selected: every level of its list style.
if let Some(eff) = eff {
for (l, eff) in (0..LIST_LEVELS).zip(eff) {
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
f(&mut lvl.run);
lf(&mut lvl.run, eff);
body.list_style.set(l, lvl);
}
}
@@ -67,6 +98,25 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
ok()
}
/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so
/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared
/// value. `a`/`b` are clamped to the body.
fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) {
let Some(last) = body.paragraphs.len().checked_sub(1) else { return };
let (first, end) = (a.0.min(last), b.0.min(last));
for pi in first..=end {
let p = &body.paragraphs[pi];
let from = if pi == first { a.1.min(p.char_len()) } else { 0 };
let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() };
if from == to && p.char_len() > 0 {
continue;
}
let lvl = p.level.min(LIST_LEVELS - 1);
let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue };
ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff));
}
}
/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it.
const LIST_LEVELS: u8 = 9;
@@ -76,6 +126,27 @@ fn editing_master(st: &DocState) -> bool {
matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. })
}
/// The distinct list-style levels of the paragraphs `range` touches in a layout or master
/// placeholder, with each level's effective run props. Empty anywhere else.
fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> {
if !editing_master(st) || t.notes || t.cell.is_some() {
return Vec::new();
}
let (Some(sh), Some(body), Some(ctx)) =
(st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection))
else {
return Vec::new();
};
let mut levels: Vec<u8> =
body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect();
if body.paragraphs.is_empty() {
levels.push(0);
}
levels.sort_unstable();
levels.dedup();
levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect()
}
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink
/// edits this same span, so the dialog reads it too.
@@ -128,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
let st = s.doc()?;
if let Some(t) = st.selection.text.clone() {
let (a, b) = t.ordered();
// Only the list-style levels of the paragraphs touched, on a layout or master placeholder.
let levels: Vec<u8> = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect();
edit_text(s, |body, _| {
if body.paragraphs.is_empty() {
body.paragraphs.push(Default::default());
@@ -138,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
f(p);
}
}
for l in levels {
format_list_level(body, l, f);
}
})?;
return ok();
}
@@ -167,16 +243,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
body.paragraphs.push(Default::default());
}
body.paragraphs.iter_mut().for_each(f);
// The whole shape is selected: every level of its list style.
if inherited {
for l in 0..LIST_LEVELS {
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() };
f(&mut p);
// A level change (indent/outdent) means nothing for a list-style level.
if p.level == l {
lvl.para = p.props;
body.list_style.set(l, lvl);
}
format_list_level(body, l, f);
}
}
}
@@ -189,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
ok()
}
/// Apply a paragraph change to one level of a body's list style.
fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) {
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() };
f(&mut p);
// A level change (indent/outdent) means nothing for a list-style level.
if p.level == l {
lvl.para = p.props;
body.list_style.set(l, lvl);
}
}
/// Apply body (text box) changes to the edited or selected shapes.
pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result<Value> {
let st = s.doc()?;