mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-26 03:27:03 -06:00
Merge PR #558 from NikitkaRa: Edit: Paste Special (destination theme, keep source, picture, text only)
# Conflicts: # crates/engine/src/tests.rs
This commit is contained in:
@@ -153,7 +153,7 @@ fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Bounds of shapes (rotation included).
|
||||
fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
let mut it = xs.iter().map(Xfrm::bounds);
|
||||
let first = it.next()?;
|
||||
Some(it.fold(first, |a, b| a.union(b)))
|
||||
|
||||
@@ -16,6 +16,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy),
|
||||
cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste),
|
||||
cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special",
|
||||
["Home", "Clipboard"],
|
||||
None,
|
||||
"{as: destinationTheme|keepSource|picture|text} — Use Destination Theme, Keep Source Formatting, Picture (PNG), Keep Text Only",
|
||||
has_doc,
|
||||
paste_special
|
||||
),
|
||||
cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text),
|
||||
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate),
|
||||
cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete),
|
||||
@@ -114,7 +123,8 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
media_of_shapes(&shapes, &mut mids);
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
|
||||
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), ..Default::default() };
|
||||
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
|
||||
Ok(plain)
|
||||
}
|
||||
|
||||
@@ -323,6 +333,113 @@ fn paste_plain(s: &mut Session, text: &str) -> Result<Value> {
|
||||
s.execute("insert.textBox", &json!({"rect": rect, "text": text}))
|
||||
}
|
||||
|
||||
fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match str_param(p, "as").unwrap_or("destinationTheme") {
|
||||
"destinationTheme" | "useDestinationTheme" => paste(s, &json!({})),
|
||||
"text" | "keepTextOnly" => paste_text(s, &json!({})),
|
||||
"keepSource" | "keepSourceFormatting" => {
|
||||
let saved = s.clipboard.clone();
|
||||
if let Some((scheme, fonts)) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
|
||||
}
|
||||
let r = paste(s, &json!({}));
|
||||
let pastes = s.clipboard.pastes;
|
||||
s.clipboard = saved;
|
||||
s.clipboard.pastes = pastes;
|
||||
r
|
||||
}
|
||||
"picture" => paste_picture(s),
|
||||
other => Err(bad("edit.pasteSpecial", format!("unknown `as`: {other} (destinationTheme, keepSource, picture, text)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
|
||||
/// shapes keep their look under another theme.
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
|
||||
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("kind").and_then(Value::as_str) == Some("scheme")
|
||||
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
|
||||
&& slot != deckcraft_color::SchemeSlot::PhClr
|
||||
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
|
||||
{
|
||||
o.clear();
|
||||
o.insert("kind".into(), json!("rgb"));
|
||||
o.insert("rgb".into(), rgb);
|
||||
return;
|
||||
}
|
||||
for x in o.values_mut() {
|
||||
rec(x, scheme, fonts, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
|
||||
Value::String(t) if t.starts_with('+') => {
|
||||
let set = match t.get(1..3) {
|
||||
Some("mj") => &fonts.major,
|
||||
Some("mn") => &fonts.minor,
|
||||
_ => return,
|
||||
};
|
||||
let face = match t.get(3..) {
|
||||
Some("-lt") => &set.latin,
|
||||
Some("-ea") => &set.ea,
|
||||
Some("-cs") => &set.cs,
|
||||
_ => return,
|
||||
};
|
||||
if !face.is_empty() {
|
||||
*t = face.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for sh in shapes.iter_mut() {
|
||||
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
|
||||
rec(&mut v, scheme, fonts, 0);
|
||||
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
|
||||
*sh = baked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste Special › Picture: the copied shapes drawn into one PNG picture.
|
||||
fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
let mut clip = s.clipboard.clone();
|
||||
if clip.shapes.is_empty() {
|
||||
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
|
||||
}
|
||||
if let Some((scheme, fonts)) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, scheme, fonts);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut doc = (*st.doc).clone();
|
||||
bring_media(&mut doc, &clip.media);
|
||||
let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?;
|
||||
let xs: Vec<deckcraft_geom::Xfrm> = clip.shapes.iter().filter_map(|x| x.xfrm).collect();
|
||||
let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?;
|
||||
let gx = deckcraft_geom::Xfrm::new(b.x0, b.y0, b.width(), b.height());
|
||||
let gid = ShapeId(u32::MAX - 1);
|
||||
let one = if clip.shapes.len() == 1 {
|
||||
let mut sh = clip.shapes[0].clone();
|
||||
sh.id = gid;
|
||||
sh
|
||||
} else {
|
||||
Shape { id: gid, xfrm: Some(gx), kind: deckcraft_model::ShapeKind::Group { children: clip.shapes.clone(), child: gx }, ..Default::default() }
|
||||
};
|
||||
slide.shapes = vec![one];
|
||||
const SCALE: f64 = 2.0;
|
||||
let img = deckcraft_render::render_shape(&doc, &slide, gid, SCALE).ok_or_else(|| bad("edit.pasteSpecial", "the shapes could not be drawn"))?;
|
||||
// render_shape draws the bounds plus an 8 pt margin (room for shadows and outlines).
|
||||
let (w, h) = (img.width as f64 / SCALE, img.height as f64 / SCALE);
|
||||
let same_slide = clip.shapes.iter().any(|c| st.shape(c.id).is_some());
|
||||
let off = if same_slide { s.prefs.duplicate_offset } else { 0.0 };
|
||||
let png = img.to_png();
|
||||
s.execute("insert.picture", &json!({"data": base64_encode(&png), "name": "Picture.png", "rect": [b.x0 - 8.0 + off, b.y0 - 8.0 + off, w, h]}))
|
||||
}
|
||||
|
||||
fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone());
|
||||
paste_plain(s, &t)
|
||||
|
||||
@@ -327,6 +327,9 @@ pub struct Clipboard {
|
||||
pub media: Vec<deckcraft_model::MediaItem>,
|
||||
pub text: Option<deckcraft_model::TextBody>,
|
||||
pub plain: String,
|
||||
/// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›
|
||||
/// Keep Source Formatting.
|
||||
pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
|
||||
/// How many times the shapes were pasted (each paste offsets further).
|
||||
pub pastes: u32,
|
||||
}
|
||||
|
||||
@@ -1566,3 +1566,55 @@ fn numbering_takes_color_and_size() {
|
||||
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
|
||||
assert_eq!(para(&s).bullet_size, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_keeps_source_look_picture_or_text() {
|
||||
use deckcraft_model::style::{ColorBase, Fill};
|
||||
let mut s = session();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Q3 plan"})).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "accent1"})).unwrap();
|
||||
let old_accent = {
|
||||
let d = s.doc().unwrap();
|
||||
let sl = d.current_slide().unwrap();
|
||||
d.doc.master_for(sl).unwrap().0.scheme().get(deckcraft_color::SchemeSlot::Accent1)
|
||||
};
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("design.theme", &json!({"name": "Ember"})).unwrap();
|
||||
{
|
||||
let d = s.doc().unwrap();
|
||||
let sl = d.current_slide().unwrap();
|
||||
assert_ne!(d.doc.master_for(sl).unwrap().0.scheme().get(deckcraft_color::SchemeSlot::Accent1), old_accent);
|
||||
}
|
||||
let fill_of = |s: &Session, id: &Value| {
|
||||
let sid = deckcraft_model::ShapeId(id.as_u64().unwrap() as u32);
|
||||
s.doc().unwrap().shape(sid).unwrap().fill.clone()
|
||||
};
|
||||
|
||||
let dest = s.execute("edit.pasteSpecial", &json!({"as": "destinationTheme"})).unwrap();
|
||||
assert!(matches!(fill_of(&s, &dest["ids"][0]), Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Scheme { .. })));
|
||||
|
||||
let keep = s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
|
||||
match fill_of(&s, &keep["ids"][0]) {
|
||||
Some(Fill::Solid { color }) => assert_eq!(color.base, ColorBase::Rgb { rgb: old_accent }),
|
||||
f => panic!("{f:?}"),
|
||||
}
|
||||
// The clipboard itself still holds the theme-following shape.
|
||||
assert!(matches!(&s.clipboard.shapes[0].fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Scheme { .. })));
|
||||
|
||||
let pic = s.execute("edit.pasteSpecial", &json!({"as": "picture"})).unwrap();
|
||||
let pid = deckcraft_model::ShapeId(pic["id"].as_u64().or_else(|| pic["ids"][0].as_u64()).unwrap() as u32);
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.shape(pid).unwrap();
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &sh.kind else { panic!("{:?}", sh.kind) };
|
||||
assert_eq!(d.doc.media(fill.media).unwrap().content_type, "image/png");
|
||||
let x = sh.xfrm.unwrap();
|
||||
assert!((x.w - 216.0).abs() < 1.0 && (x.h - 116.0).abs() < 1.0, "{x:?}");
|
||||
|
||||
let n = s.doc().unwrap().current_slide().unwrap().shapes.len();
|
||||
s.execute("edit.pasteSpecial", &json!({"as": "text"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let last = d.current_slide().unwrap().shapes.last().unwrap();
|
||||
assert_eq!(d.current_slide().unwrap().shapes.len(), n + 1);
|
||||
assert_eq!(last.text.as_ref().unwrap().text(), "Q3 plan");
|
||||
assert!(s.execute("edit.pasteSpecial", &json!({"as": "bogus"})).is_err());
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ fn title(id: &str) -> &'static str {
|
||||
"symbol" => "Symbol",
|
||||
"equation" => "Equation",
|
||||
"spelling" => "Spelling",
|
||||
"pasteSpecial" => "Paste Special",
|
||||
"accessibility" => "Accessibility Checker",
|
||||
"language" => "Language",
|
||||
"customShow" => "Custom Shows",
|
||||
@@ -622,6 +623,27 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"pasteSpecial" => {
|
||||
const OPTS: [(&str, &str); 4] = [
|
||||
("destinationTheme", "Use Destination Theme"),
|
||||
("keepSource", "Keep Source Formatting"),
|
||||
("picture", "Picture (PNG)"),
|
||||
("text", "Keep Text Only"),
|
||||
];
|
||||
let has_shapes = !app.session.clipboard.shapes.is_empty();
|
||||
let mut cur = d.get("as", "destinationTheme");
|
||||
ui.label("As:");
|
||||
for (id, label) in OPTS {
|
||||
let ok = has_shapes || matches!(id, "destinationTheme" | "text");
|
||||
ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label));
|
||||
}
|
||||
d.fields.insert("as".into(), cur.clone());
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
run(app, "edit.pasteSpecial", json!({"as": cur}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"spelling" => {
|
||||
let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default();
|
||||
let list = v.as_array().cloned().unwrap_or_default();
|
||||
|
||||
@@ -185,6 +185,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.saveAsDialog", "Save As…", None, "{}"),
|
||||
("app.exportDialog", "Export…", None, "{}"),
|
||||
("app.dialog", "Open Dialog", None, "{id}"),
|
||||
("app.pasteSpecial", "Paste Special…", Some("Cmd+Alt+V"), "{}"),
|
||||
("app.links", "Community Links", None, "{}"),
|
||||
];
|
||||
|
||||
@@ -578,6 +579,7 @@ impl SlideApp {
|
||||
"app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),
|
||||
"app.saveAsDialog" => self.save_as_dialog("deckcraft"),
|
||||
"app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")),
|
||||
"app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")),
|
||||
"app.dialog" => {
|
||||
let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?;
|
||||
self.dialog = Some(dialogs::Dialog::new(d));
|
||||
|
||||
@@ -49,6 +49,10 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool {
|
||||
return true;
|
||||
}
|
||||
(O, true, false, false) => "app.openDialog",
|
||||
(V, true, false, true) if app.session.active().is_some() => {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("pasteSpecial"));
|
||||
return true;
|
||||
}
|
||||
(P, true, true, false) => "app.palette",
|
||||
(Comma, true, false, false) => "app.preferences",
|
||||
(Equals | Plus, true, false, false) => "view.zoomIn",
|
||||
@@ -177,6 +181,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("Copy", "edit.copy"),
|
||||
("Paste", "edit.paste"),
|
||||
("Paste and Match Formatting", "edit.pasteText"),
|
||||
("Paste Special…", "app.pasteSpecial"),
|
||||
("-", ""),
|
||||
("Select All", "edit.selectAll"),
|
||||
("Duplicate", "edit.duplicate"),
|
||||
|
||||
@@ -295,6 +295,9 @@ fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if ui.button("Paste and Match Formatting").clicked() {
|
||||
run(app, "edit.pasteText", json!({}));
|
||||
}
|
||||
if ui.button("Paste Special…").clicked() {
|
||||
run(app, "app.pasteSpecial", json!({}));
|
||||
}
|
||||
if ui.button("Paste Formatting").clicked() {
|
||||
run(app, "format.painterApply", json!({}));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
> **Generated:** 2026-10-10 (summary by `cargo xtask parity`; rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 79%** over 191 features.
|
||||
**Weighted breadth parity: 80%** over 191 features.
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 61 | 11 | 0 | 92% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P1 | 65 | 39 | 20 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 15 | 26 | 38% |
|
||||
|
||||
| Area | Parity |
|
||||
@@ -32,7 +32,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| Slide show | 66% |
|
||||
| Review | 60% |
|
||||
| Draw | 33% |
|
||||
| Undo & clipboard | 88% |
|
||||
| Undo & clipboard | 100% |
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
|
||||
@@ -227,7 +227,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Draw | Ink to shape / text | P2 | M |
|
||||
| Undo & clipboard | Unlimited undo/redo | P0 | D |
|
||||
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | P |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D |
|
||||
| Automation (✱) | Every command scriptable (CLI, control channel, MCP) | P0 | D |
|
||||
| Automation (✱) | Headless rendering and conversion | P0 | D |
|
||||
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
|
||||
|
||||
Reference in New Issue
Block a user