diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index b17258acd..b66041a04 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -27,7 +27,8 @@ winresource = "0.1" # Not called here: craft-dialogs shows the dialogs. rfd's default features keep its Wayland # support on, so portal dialogs get the window as their parent. rfd = { workspace = true } -arboard = { workspace = true } +# `image-data`: Edit › Paste reads bitmaps from the clipboard (src/clipboard_image.rs). +arboard = { workspace = true, features = ["image-data"] } [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] # The same winit eframe uses: to pick the X11 backend (file drag-and-drop) in main.rs. @@ -46,9 +47,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"] } winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } # The input context, to drop the IME's marked text when the Type tool ends a composition. The -# versions muda uses, through safe methods. +# versions muda uses, through safe methods. `NSPasteboard`: the pasteboard's change count, which +# tells Paste whether anything was copied since an object Copy (src/clipboard_image.rs). objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSResponder", "NSScreen", "NSTextInputContext"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSPasteboard", "NSResponder", "NSScreen", "NSTextInputContext"] } # Files dragged in from the Finder: the pointer's place on the screen (src/pointer.rs). objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] } # The window's `movable` flag follows the application bar's controls. diff --git a/apps/designcraft/src/clipboard_image.rs b/apps/designcraft/src/clipboard_image.rs new file mode 100644 index 000000000..0e8339ff4 --- /dev/null +++ b/apps/designcraft/src/clipboard_image.rs @@ -0,0 +1,135 @@ +//! The system clipboard's image for Edit › Paste (`Services::clipboard_image`): a bitmap +//! (Windows `CF_DIBV5` or `PNG`, the macOS pasteboard's TIFF or PNG, `image/png` on X11), else the +//! first image file of files copied in a file manager. The read runs on its own thread, and Paste +//! waits for it at most [`WAIT`], so an application that is slow to hand over the clipboard does +//! not freeze the window. While a read that timed out is still waiting, Paste starts no other. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::time::Duration; + +use designcraft_ui_egui::ClipboardImage; + +/// How long Paste waits for the clipboard's image. +const WAIT: Duration = Duration::from_millis(1500); + +/// The largest image file Paste reads from a copied file list, in bytes. +const MAX_FILE_BYTES: u64 = 256 << 20; + +/// A clipboard read is running. +static READING: AtomicBool = AtomicBool::new(false); + +/// The clipboard's image, or `None` when it holds none or does not answer within [`WAIT`]. +pub fn read() -> Option { + within(&READING, WAIT, read_now) +} + +/// Clears the in-flight flag when the read's thread ends. +struct Reading(&'static AtomicBool); + +impl Drop for Reading { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +/// `read`'s result when it arrives within `wait`. A read that takes longer finishes on its own +/// thread and is dropped; until it does, `busy` stays set and no other read starts. +fn within(busy: &'static AtomicBool, wait: Duration, read: impl FnOnce() -> Option + Send + 'static) -> Option { + if busy.swap(true, Ordering::AcqRel) { + log::warn!("clipboard image: the previous read has not finished"); + return None; + } + let (tx, rx) = mpsc::channel(); + let spawned = std::thread::Builder::new().name("clipboard image".into()).spawn(move || { + let _reading = Reading(busy); + let _ = tx.send(read()); + }); + if let Err(e) = spawned { + busy.store(false, Ordering::Release); + log::warn!("clipboard image: no thread to read it: {e}"); + return None; + } + match rx.recv_timeout(wait) { + Ok(image) => image, + Err(mpsc::RecvTimeoutError::Timeout) => { + log::warn!("clipboard image: the clipboard did not answer within {} ms", wait.as_millis()); + None + } + Err(mpsc::RecvTimeoutError::Disconnected) => None, + } +} + +/// The general pasteboard's change count, which AppKit raises with every copy. +#[cfg(target_os = "macos")] +pub fn change_count() -> Option { + i64::try_from(objc2_app_kit::NSPasteboard::generalPasteboard().changeCount()).ok() +} + +fn read_now() -> Option { + let mut clipboard = arboard::Clipboard::new().ok()?; + if let Ok(image) = clipboard.get_image() { + let width = u32::try_from(image.width).ok()?; + let height = u32::try_from(image.height).ok()?; + return Some(ClipboardImage::Pixels { width, height, rgba: image.bytes.into_owned() }); + } + first_image_file(&clipboard.get().file_list().ok()?) +} + +/// The first of `paths` that is an image file no larger than [`MAX_FILE_BYTES`], with its name. +fn first_image_file(paths: &[PathBuf]) -> Option { + paths.iter().find_map(|path| { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + if !designcraft_engine::cmd::IMAGE_EXTENSIONS.contains(&ext.as_str()) || std::fs::metadata(path).ok()?.len() > MAX_FILE_BYTES { + return None; + } + // At most one byte past the limit is read, should the file grow after the size check. + let bytes = designcraft_engine::io::read_capped(path, MAX_FILE_BYTES).ok().filter(|b| b.len() as u64 <= MAX_FILE_BYTES)?; + Some(ClipboardImage::File { name: file_name(path), bytes }) + }) +} + +fn file_name(path: &Path) -> String { + path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_read_that_does_not_answer_is_given_up_and_no_second_one_starts_meanwhile() { + static BUSY: AtomicBool = AtomicBool::new(false); + let (release, blocked) = mpsc::channel::<()>(); + // The read waits for a message that is only sent after Paste has given up on it. + assert_eq!(within(&BUSY, Duration::ZERO, move || blocked.recv().ok().map(|()| 1)), None); + let (started, ran) = mpsc::channel::<()>(); + let second = within(&BUSY, Duration::from_secs(60), move || started.send(()).ok().map(|()| 2)); + assert_eq!(second, None, "refused while the first read waits"); + assert!(ran.try_recv().is_err(), "the second read never ran"); + let _ = release.send(()); + while BUSY.load(Ordering::Acquire) { + std::thread::yield_now(); + } + assert_eq!(within(&BUSY, Duration::from_secs(60), || Some(3)), Some(3), "an answer is taken as soon as it comes"); + } + + #[test] + fn a_copied_file_list_pastes_its_first_image_file() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-clipboard-files-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let (text, missing, png) = (dir.join("notes.txt"), dir.join("gone.png"), dir.join("Photo.PNG")); + std::fs::write(&text, b"text").unwrap(); + std::fs::write(&png, b"png bytes").unwrap(); + let picked = first_image_file(&[text, missing, png.clone()]); + let none = first_image_file(&[dir.join("a.txt")]); + let _ = std::fs::remove_dir_all(&dir); + match picked { + Some(ClipboardImage::File { name, bytes }) => assert_eq!((name.as_str(), bytes.as_slice()), ("Photo.PNG", b"png bytes".as_slice())), + _ => panic!("the image file is picked"), + } + assert!(none.is_none()); + } +} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index ab6970332..5f465e46d 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -15,6 +15,7 @@ #[cfg(target_os = "macos")] mod apple_events; +mod clipboard_image; mod control_server; mod gpu; mod gpu_limits; @@ -227,6 +228,9 @@ fn services() -> Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), + clipboard_image: Some(Box::new(clipboard_image::read)), + #[cfg(target_os = "macos")] + clipboard_change: Some(Box::new(clipboard_image::change_count)), #[cfg(target_os = "macos")] pointer: Some(Box::new(pointer::screen_position)), ..Default::default() diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index dc1acaf15..20260a5ad 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -44,6 +44,7 @@ mod object; mod overrides; mod package; pub mod params; +mod paste_image; mod paste_svg; mod path_type; mod paths; @@ -179,6 +180,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(export::specs()); v.extend(export_batch::specs()); v.extend(edit::specs()); + v.extend(paste_image::specs()); v.extend(paste_svg::specs()); v.extend(endnotes::specs()); v.extend(editorial::specs()); @@ -376,5 +378,6 @@ pub use file::{ DOCUMENT_SNIFF_BYTES, RecentPreview, base64_decode, base64_encode, from_bytes, is_document, recent_preview, to_bytes, with_document_extension, }; pub use layout::chapter_numbering; +pub use paste_image::{IMAGE_EXTENSIONS, MAX_PASTED_PIXELS, PASTED_IMAGE_NAME, check_pasted_size, pasted_pixels, pasted_png}; pub use paste_svg::is_svg_markup; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/cmd/paste_image.rs b/crates/engine/src/cmd/paste_image.rs new file mode 100644 index 000000000..d5197a90c --- /dev/null +++ b/crates/engine/src/cmd/paste_image.rs @@ -0,0 +1,251 @@ +//! Paste a bitmap from the system clipboard (a screenshot, an image copied in a web browser or an +//! image editor, an image file copied in a file manager) as a placed image. The pixels are +//! embedded in the document, so a save and reopen or an IDML export keeps them. + +use designcraft_doc::Content; +use designcraft_geom::Point; +use serde_json::{Value, json}; + +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, spread_param}; + +const ID: &str = "edit.pasteImage"; + +/// The most pixels a pasted image may have (8,000 × 8,000). +pub const MAX_PASTED_PIXELS: u64 = 64_000_000; + +/// The largest pasted image file, in bytes. +const MAX_PASTED_BYTES: usize = 256 << 20; + +/// The extensions a pasted image's name may end in. +pub const IMAGE_EXTENSIONS: [&str; 8] = ["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; + +/// The name a pasted bitmap gets in the Links panel when it comes with none. +pub const PASTED_IMAGE_NAME: &str = "Pasted image.png"; + +pub fn specs() -> Vec { + vec![cmd!( + "edit.pasteImage", + "Paste Image", + [], + None, + "{base64: an image file (PNG/JPEG/GIF/WebP/TIFF/BMP) as base64, name?: its name in the Links panel ending in an image extension (default \"Pasted image.png\"), spread?, at?: [x, y] (spread coords of the image's center; default the first page's center)} → {id, asset} — embedded; a selected empty frame takes the image, otherwise a new frame holds it at its stored resolution (72 ppi when none) and at most 60% of the page width", + has_doc, + paste_image + )] +} + +/// Whether a `width × height` bitmap is small enough to paste; the error says why not. +pub fn check_pasted_size(width: u32, height: u32) -> std::result::Result<(), String> { + if width == 0 || height == 0 { + return Err("the image on the clipboard has no pixels".into()); + } + if u64::from(width) * u64::from(height) > MAX_PASTED_PIXELS { + return Err(format!( + "the image on the clipboard is {width} × {height} pixels; images of up to 64 million pixels can be pasted (save it and use File › Place)" + )); + } + Ok(()) +} + +/// Unpremultiplied RGBA pixels from the clipboard, rows top to bottom, as a PNG file for +/// [`edit.pasteImage`](specs). +pub fn pasted_png(width: u32, height: u32, rgba: Vec) -> std::result::Result, String> { + check_pasted_size(width, height)?; + if (rgba.len() as u64) != u64::from(width) * u64::from(height) * 4 { + return Err("the image on the clipboard has the wrong number of pixels".into()); + } + designcraft_images::png_from_rgba(width, height, rgba).ok_or_else(|| "the image on the clipboard can't be stored".to_string()) +} + +/// An image file's pixels: (width, height, unpremultiplied RGBA). `None` when it is not a raster +/// DesignCraft reads or has more pixels than [`MAX_PASTED_PIXELS`]. +pub fn pasted_pixels(bytes: &[u8]) -> Option<(u32, u32, Vec)> { + let (w, h) = raster_size(bytes)?; + check_pasted_size(w, h).ok()?; + let img = designcraft_images::decode_rgba(bytes)?; + Some((img.width(), img.height(), img.into_raw())) +} + +/// A raster's pixel size; `None` for vector formats (PDF, SVG, EPS) and unreadable data. +fn raster_size(bytes: &[u8]) -> Option<(u32, u32)> { + if designcraft_images::is_pdf(bytes) || designcraft_images::is_svg(bytes) || designcraft_images::is_eps(bytes) { + return None; + } + designcraft_images::pixel_size(bytes) +} + +fn paste_image(s: &mut Session, p: &Value) -> Result { + let b64 = match p.get("base64") { + Some(Value::String(b)) if !b.is_empty() => b, + Some(_) => return Err(bad(ID, "`base64` must be a non-empty string")), + None => return Err(bad(ID, "missing base64")), + }; + if b64.len() / 4 * 3 > MAX_PASTED_BYTES { + return Err(bad(ID, format!("the image is larger than {} MB", MAX_PASTED_BYTES >> 20))); + } + let name = match p.get("name") { + None | Some(Value::Null) => PASTED_IMAGE_NAME, + Some(Value::String(n)) if !n.trim().is_empty() => n.as_str(), + Some(_) => return Err(bad(ID, "`name` must be a non-empty string")), + }; + // File › Place picks text and layout import by the name's extension: keep it an image's. + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); + if !IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(bad(ID, format!("`name` must end in an image extension ({})", IMAGE_EXTENSIONS.join(", ")))); + } + let bytes = super::base64_decode(b64); + let (pw, ph) = raster_size(&bytes).ok_or_else(|| bad(ID, "the clipboard doesn't hold an image DesignCraft can read"))?; + check_pasted_size(pw, ph).map_err(|e| bad(ID, e))?; + let sr = spread_param(p, ID)?; + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(_) => { + let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "`at` must be [x, y]"))?; + if !(at.x.is_finite() && at.y.is_finite()) { + return Err(bad(ID, "`at` must be finite")); + } + Some(at) + } + }; + // A selected empty frame takes the image, as File › Place fills it. + let st = s.doc()?; + let empty_frame = match st.selection.items.as_slice() { + [only] if st.doc.item(*only).is_some_and(|it| !it.locked && it.path.encloses_area() && matches!(it.content, Content::Unassigned)) => { + Some(only.0) + } + _ => None, + }; + if let Some(frame) = empty_frame { + return s.execute("file.place", &json!({"base64": b64, "name": name, "frame": frame})); + } + let d = &s.doc()?.doc; + let sp = d.spread(sr).ok_or_else(|| bad(ID, "no such spread"))?; + let center = match at { + Some(at) => at, + None => sp.pages.first().map_or(Point::ZERO, |pg| pg.bounds().center()), + }; + // The size File › Place gives it: the stored resolution (72 ppi when none), at most 60% of + // the page width. + let (nw, nh) = designcraft_render::natural_size(&bytes, 0).unwrap_or((f64::from(pw), f64::from(ph))); + let w = nw.min(d.settings.page_width * 0.6); + let h = nh * w / nw; + let spread = p.get("spread").cloned().unwrap_or(Value::Null); + s.execute("file.place", &json!({"base64": b64, "name": name, "spread": spread, "x": center.x - w / 2.0, "y": center.y - h / 2.0})) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::{base64_decode, base64_encode}; + use designcraft_doc::{ItemId, SpreadRef}; + + /// A `w × h` PNG whose pixels count up, so a changed pixel shows. + fn png(w: u32, h: u32) -> Vec { + let rgba: Vec = (0..w * h * 4).map(|i| (i % 251) as u8).collect(); + pasted_png(w, h, rgba).unwrap() + } + + fn doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s + } + + fn placed_png(s: &Session, id: ItemId) -> Vec { + let d = &s.doc().unwrap().doc; + let Content::Graphic(g) = &d.item(id).unwrap().content else { panic!("not a graphic") }; + let a = &d.assets[&g.asset]; + assert_eq!(a.link, None, "embedded, not linked"); + a.data.to_vec() + } + + #[test] + fn a_pasted_bitmap_is_embedded_at_the_center_and_kept_by_save_and_idml() { + let mut s = doc(); + let png = png(40, 30); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(ID, &json!({"base64": base64_encode(&png), "at": [300, 400]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let id = ItemId(r["id"].as_u64().unwrap()); + let st = s.doc().unwrap(); + assert_eq!(st.selection.items, [id], "the pasted image is selected"); + let b = st.doc.item(id).unwrap().bounds(); + assert!( + (b.x0 - 280.0).abs() < 1e-9 && (b.y0 - 385.0).abs() < 1e-9 && (b.width() - 40.0).abs() < 1e-9 && (b.height() - 30.0).abs() < 1e-9, + "{b:?}" + ); + assert_eq!(placed_png(&s, id), png); + assert_eq!(s.doc().unwrap().doc.assets[&designcraft_doc::AssetId(r["asset"].as_u64().unwrap())].name, PASTED_IMAGE_NAME); + + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": "a.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(placed_png(&t, id), png, "saved with the document"); + + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let imported = designcraft_idml::import_idml(&base64_decode(idml["base64"].as_str().unwrap())).unwrap(); + assert!(imported.assets.values().any(|a| *a.data == png), "the IDML package carries the pixels"); + } + + #[test] + fn without_a_point_the_image_lands_in_the_middle_of_the_first_page_and_shrinks_to_the_page() { + let mut s = doc(); + let r = s.execute(ID, &json!({"base64": base64_encode(&png(1000, 10))})).unwrap(); + let d = &s.doc().unwrap().doc; + let b = d.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); + let page = d.spread(SpreadRef::Doc(0)).unwrap().pages[0].bounds(); + let w = d.settings.page_width * 0.6; + assert!((b.width() - w).abs() < 1e-9 && (b.height() - w / 100.0).abs() < 1e-9, "{b:?}"); + assert!((b.center().x - page.center().x).abs() < 1e-9 && (b.center().y - page.center().y).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn a_selected_empty_frame_takes_the_image() { + let mut s = doc(); + let frame = s.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [frame]})).unwrap(); + let items = s.doc().unwrap().doc.spreads[0].items.len(); + let r = s.execute(ID, &json!({"base64": base64_encode(&png(10, 10)), "at": [500, 500]})).unwrap(); + assert_eq!(r["id"].as_u64(), Some(frame)); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), items, "no new frame"); + // A frame that already holds a graphic gets a new frame beside it instead. + let r = s.execute(ID, &json!({"base64": base64_encode(&png(10, 10)), "at": [500, 500]})).unwrap(); + assert_ne!(r["id"].as_u64(), Some(frame)); + assert_eq!(s.doc().unwrap().doc.spreads[0].items.len(), items + 1); + } + + #[test] + fn bad_input_is_refused_and_leaves_no_undo_step() { + let mut s = doc(); + let ok = base64_encode(&png(2, 2)); + let undo = s.doc().unwrap().history.undo.len(); + for (p, want) in [ + (json!({}), "missing base64"), + (json!({"base64": 5}), "`base64` must be a non-empty string"), + (json!({"base64": ""}), "`base64` must be a non-empty string"), + (json!({"base64": base64_encode(b"just text")}), "doesn't hold an image"), + (json!({"base64": base64_encode(b"")}), "doesn't hold an image"), + (json!({"base64": ok, "name": ""}), "`name` must be a non-empty string"), + (json!({"base64": ok, "name": 3}), "`name` must be a non-empty string"), + (json!({"base64": ok, "name": "notes.txt"}), "`name` must end in an image extension"), + (json!({"base64": ok, "name": "page.idml"}), "`name` must end in an image extension"), + (json!({"base64": ok, "name": "png"}), "`name` must end in an image extension"), + (json!({"base64": ok, "at": [1]}), "`at` must be [x, y]"), + (json!({"base64": ok, "spread": 9}), "no such spread"), + ] { + let e = s.execute(ID, &p).unwrap_err().to_string(); + assert!(e.contains(want), "{p}: {e}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed"); + } + + #[test] + fn images_beyond_the_pixel_limit_are_refused_before_they_are_encoded() { + let e = pasted_png(9000, 8000, Vec::new()).unwrap_err(); + assert!(e.contains("9000 × 8000 pixels") && e.contains("64 million"), "{e}"); + assert!(pasted_png(0, 5, Vec::new()).is_err()); + assert!(pasted_png(2, 2, vec![0; 15]).unwrap_err().contains("wrong number of pixels")); + assert_eq!(pasted_pixels(&png(3, 2)).map(|(w, h, px)| (w, h, px.len())), Some((3, 2, 24))); + assert_eq!(pasted_pixels(b"%PDF-1.4"), None, "vector files are not pasted as pixels"); + } +} diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 83c540e6e..02f978b6a 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -373,6 +373,15 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> { Some((w as f64, h as f64)) } +/// Unpremultiplied RGBA pixels, rows top to bottom, as a PNG file with no resolution stored. +/// `None` when `rgba` doesn't hold exactly `width × height` pixels. +pub fn png_from_rgba(width: u32, height: u32, rgba: Vec) -> Option> { + let img = image::RgbaImage::from_raw(width, height, rgba)?; + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + /// Pixel dimensions of a raster. pub fn pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { if let Some(p) = palette_tiff(bytes) { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 88bd48c58..8355e236f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -113,6 +113,7 @@ impl DesignApp { egui_ctx: None, in_control: false, object_clip_text: None, + object_clip_change: None, ime_marked: None, ime_discard: false, canvas_ime: false, diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 4b813dd5c..bff5156f7 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -1,13 +1,35 @@ use super::{DesignApp, Value, canvas, json}; +use crate::services::ClipboardImage; + +/// What Paste takes from the system clipboard. +pub(super) enum Pasted { + /// The command's params, with the clipboard's text when it has some. + Params(Value), + /// An image, pasted as a placed image instead. + Image(ClipboardImage), +} + +/// Text that is only the address of a file or a web page: what a browser puts beside a copied +/// image. Paste prefers the image to such text. +fn is_bare_address(text: &str) -> bool { + let t = text.trim(); + !t.is_empty() + && !t.contains(char::is_whitespace) + && ["http://", "https://", "file://", "data:image/"] + .iter() + .any(|scheme| t.get(..scheme.len()).is_some_and(|s| s.eq_ignore_ascii_case(scheme))) +} impl DesignApp { /// Paste without the clipboard's text (a menu, not the paste keys): read it here. With no /// insertion point, text that doesn't stand for copied objects becomes a new text frame - /// centred in the view. - pub(super) fn with_system_clipboard(&mut self, id: &str, mut params: Value) -> Value { + /// centered in the view, and an image on the clipboard (when there is no text, or the text is + /// only an address) becomes a placed image, unless nothing was copied to the clipboard since + /// objects were last copied here. + pub(super) fn with_system_clipboard(&mut self, id: &str, mut params: Value) -> Pasted { let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); if !matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") || !(typing || id == "edit.paste") { - return params; + return Pasted::Params(params); } if params.get("text").is_none() && let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f()) @@ -15,14 +37,33 @@ impl DesignApp { { o.insert("text".into(), Value::String(text)); } + // Nothing was copied to the clipboard since objects were copied here (native menu, where + // the system raises a change count with every copy). + let unchanged = match self.object_clip_change { + Some(at_copy) => self.services.clipboard_change.as_mut().and_then(|f| f()) == Some(at_copy), + None => true, + }; // Still what an object copy put there: paste those objects, not the placeholder text. let norm = |t: &str| t.replace("\r\n", "\n"); - if let Some(copied) = &self.object_clip_text - && params.get("text").and_then(Value::as_str).is_some_and(|t| norm(t) == norm(copied)) - && let Some(o) = params.as_object_mut() - { + let ours = unchanged + && self + .object_clip_text + .as_ref() + .is_some_and(|copied| params.get("text").and_then(Value::as_str).is_some_and(|t| norm(t) == norm(copied))); + if ours && let Some(o) = params.as_object_mut() { o.remove("text"); } + let text = params.get("text").and_then(Value::as_str); + let copied_here = + text.is_none() && self.session.clipboard.is_some() && self.object_clip_text.is_none() && self.object_clip_change.is_some() && unchanged; + if !typing + && !ours + && !copied_here + && text.is_none_or(is_bare_address) + && let Some(image) = self.services.clipboard_image.as_mut().and_then(|f| f()) + { + return Pasted::Image(image); + } if !typing && params.get("text").and_then(Value::as_str).is_some_and(|t| !t.is_empty()) && let Some((spread, at)) = canvas::view_centre(self) @@ -32,7 +73,39 @@ impl DesignApp { o.entry("spread").or_insert(spread); o.entry("at").or_insert(json!([at.x, at.y])); } - params + Pasted::Params(params) + } + + /// Paste an image from the system clipboard as a placed image (`edit.pasteImage`), centered + /// in the view or in the selected empty frame. Text being edited takes no image. + pub(crate) fn paste_clipboard_image(&mut self, image: ClipboardImage) -> Result { + let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); + let file = if typing { + Err("To paste the image, stop editing the text first (Esc).".to_string()) + } else { + match image { + ClipboardImage::Pixels { width, height, rgba } => { + designcraft_engine::cmd::pasted_png(width, height, rgba).map(|png| (png, designcraft_engine::cmd::PASTED_IMAGE_NAME.to_string())) + } + ClipboardImage::File { name, bytes } => Ok((bytes, name)), + } + }; + let (bytes, name) = match file { + Ok(file) => file, + Err(e) => { + self.status(e.clone()); + return Err(e); + } + }; + let mut params = json!({"base64": designcraft_engine::cmd::base64_encode(&bytes), "name": name}); + if let Some((spread, at)) = canvas::view_centre(self) + && let Ok(spread) = serde_json::to_value(spread) + && let Some(o) = params.as_object_mut() + { + o.insert("spread".into(), spread); + o.insert("at".into(), json!([at.x, at.y])); + } + self.run("edit.pasteImage", params) } /// Keep widget clipboard output private while a queued control event is running. diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index bb75c102e..dc0326172 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -8,7 +8,14 @@ impl DesignApp { } // Only user-initiated runs touch the system clipboard, never the control channel's. let system_clipboard = !self.in_control && !self.control_input_frame.load(std::sync::atomic::Ordering::Relaxed); - let mut params = if system_clipboard { self.with_system_clipboard(id, params) } else { params }; + let mut params = if system_clipboard { + match self.with_system_clipboard(id, params) { + super::clipboard::Pasted::Params(params) => params, + super::clipboard::Pasted::Image(image) => return self.paste_clipboard_image(image), + } + } else { + params + }; // Vector art copied in another app (Illustrator, VectorCraft, Figma) is SVG markup on the // system clipboard: with no insertion point it pastes as editable paths where text would go. let typing = self.session.active().is_some_and(|d| d.selection.text.is_some()); @@ -37,14 +44,17 @@ impl DesignApp { let text = match v.get("text").and_then(Value::as_str) { Some(t) => { self.object_clip_text = None; + self.object_clip_change = None; Some(t.to_string()) } None if !self.native_menu => { self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session)); + self.object_clip_change = None; self.object_clip_text.clone() } None => { self.object_clip_text = self.services.clipboard_text.as_mut().and_then(|f| f()); + self.object_clip_change = self.services.clipboard_change.as_mut().and_then(|f| f()); None } }; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 590b0f8c6..d9102e266 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -435,7 +435,13 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui:: let _ = app.run("text.insert", json!({"text": t})); } // The IME owns the keyboard until it commits: no typing, clipboard or editing keys. - egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Key { .. } if composing => {} + egui::Event::Text(_) + | egui::Event::Paste(_) + | egui::Event::PasteImage(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Key { .. } + if composing => {} egui::Event::Paste(t) if wants_text => { // Formatted when the system clipboard still holds what was copied here; // Shift (⇧⌘V) pastes without formatting. @@ -443,6 +449,10 @@ pub(super) fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui:: let id = if plain { "edit.pasteWithoutFormatting" } else { "edit.paste" }; let _ = app.run(id, json!({"text": t})); } + // A clipboard image with no text (a screenshot, an image copied in a browser). + egui::Event::PasteImage(image) => { + let _ = app.paste_clipboard_image(crate::ClipboardImage::from_color_image(&image)); + } egui::Event::Copy | egui::Event::Cut if wants_text => { // `run` puts the copied text on the system clipboard. let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" }; @@ -525,6 +535,10 @@ pub(crate) fn forwarded_keys(app: &mut DesignApp, events: Vec, modi (egui::Event::Copy, _) if !app.native_menu => ("edit.copy", serde_json::Value::Null), (egui::Event::Cut, _) if !app.native_menu => ("edit.cut", serde_json::Value::Null), (egui::Event::Paste(t), _) if !app.native_menu => ("edit.paste", json!({"text": t})), + (egui::Event::PasteImage(image), _) => { + let _ = app.paste_clipboard_image(crate::ClipboardImage::from_color_image(&image)); + continue; + } (egui::Event::Key { key, pressed: true, modifiers, .. }, _) => { tool_key(app, key, modifiers, vi); continue; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 62064ea79..0937904d9 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1051,6 +1051,127 @@ fn edit_paste_from_the_menu_pastes_svg_on_the_system_clipboard_as_paths() { assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items, "no paths added"); } +/// A fake system clipboard: its text, a bitmap of a given size and the change count that every +/// copy raises. +#[derive(Default)] +struct FakeClip { + text: Option, + image: Option<(u32, u32)>, + change: i64, +} + +type FakeClipboard = std::rc::Rc>; + +/// Another application copies `text` and an image of `image` pixels. +fn copy_elsewhere(clip: &FakeClipboard, text: Option<&str>, image: Option<(u32, u32)>) { + let mut c = clip.borrow_mut(); + (c.text, c.image, c.change) = (text.map(str::to_string), image, c.change + 1); +} + +fn fake_clipboard(text: Option<&str>, image: Option<(u32, u32)>) -> (FakeClipboard, crate::Services) { + let clip = FakeClipboard::default(); + copy_elsewhere(&clip, text, image); + let (t, i, c) = (clip.clone(), clip.clone(), clip.clone()); + let services = crate::Services { + clipboard_text: Some(Box::new(move || t.borrow().text.clone())), + clipboard_image: Some(Box::new(move || { + let (width, height) = i.borrow().image?; + // Sizes past the paste limit are refused before their pixels are read. + let len = if u64::from(width) * u64::from(height) > designcraft_engine::cmd::MAX_PASTED_PIXELS { 0 } else { width * height * 4 }; + Some(crate::ClipboardImage::Pixels { width, height, rgba: vec![90; len as usize] }) + })), + clipboard_change: Some(Box::new(move || Some(c.borrow().change))), + ..Default::default() + }; + (clip, services) +} + +/// The embedded image the selected frame shows: its pixel size and the asset's name. +fn pasted_image(app: &DesignApp) -> Option<((u32, u32), String)> { + let st = app.session.doc().unwrap(); + let id = *st.selection.items.first()?; + let designcraft_doc::Content::Graphic(g) = &st.doc.item(id)?.content else { return None }; + let asset = &st.doc.assets[&g.asset]; + assert_eq!(asset.link, None, "embedded"); + Some((asset.pixels?, asset.name.clone())) +} + +#[test] +fn edit_paste_from_the_menu_places_the_clipboard_image() { + let (clip, services) = fake_clipboard(None, Some((40, 30))); + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.native_menu = true; + app.run("file.new", json!({})).unwrap(); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), Some(((40, 30), designcraft_engine::cmd::PASTED_IMAGE_NAME.to_string()))); + // A browser puts the image's address beside it: the image wins. + copy_elsewhere(&clip, Some("https://example.com/cat.png"), Some((8, 6))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((8, 6))); + // Other text wins over a picture of it. + copy_elsewhere(&clip, Some("Some words"), Some((8, 6))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), None); + assert!(app.session.doc().unwrap().selection.text.is_none(), "a text frame, selected as an object"); + // Too many pixels: refused with a message, and nothing is added. + copy_elsewhere(&clip, None, Some((9000, 8000))); + let items = app.session.doc().unwrap().doc.spreads[0].items.len(); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items); + assert!(app.ui.status.contains("64 million"), "{}", app.ui.status); +} + +#[test] +fn objects_copied_here_still_paste_as_objects_until_another_image_is_copied() { + // A native menu: Copy leaves the system clipboard (here an older image) alone. + let (clip, services) = fake_clipboard(None, Some((40, 30))); + let mut app = DesignApp::new(designcraft_engine::Session::new(), services); + app.native_menu = true; + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200], "content": "text"})).unwrap()["id"].clone(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + crate::menus::activate(&mut app, "edit.copy", &Value::Null); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), None, "the copied text frame"); + assert_ne!(app.session.doc().unwrap().selection.items.first().map(|i| i.0), id.as_u64()); + // The same image copied again in another application is a new copy. + copy_elsewhere(&clip, None, Some((40, 30))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((40, 30)), "an image copied since then"); + // The same goes for an image whose address was on the clipboard at the copy. + let url = "https://example.com/cat.png"; + copy_elsewhere(&clip, Some(url), Some((5, 5))); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + crate::menus::activate(&mut app, "edit.copy", &Value::Null); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app), None, "the copied text frame again"); + copy_elsewhere(&clip, Some(url), Some((5, 5))); + crate::menus::activate(&mut app, "edit.paste", &Value::Null); + assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((5, 5)), "the image copied again with the same address"); +} + +#[test] +fn the_paste_keys_place_an_image_in_the_selected_empty_frame_but_not_in_text() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = app.run("frame.create", json!({"rect": [100, 100, 300, 250]})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [frame]})).unwrap(); + let mut h = harness(app); + let image = || egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([20, 10], egui::Color32::RED))); + h.input_mut().events.push(image()); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().selection.items.first().map(|i| i.0), Some(frame), "the frame took it"); + assert_eq!(pasted_image(h.state()).map(|(px, _)| px), Some((20, 10))); + // While text is edited, the image is not pasted and the status bar says why. + let story = h.state_mut().run("frame.create", json!({"rect": [10, 400, 300, 600], "content": "text"})).unwrap()["story"].clone(); + h.state_mut().run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); + let items = h.state().session.doc().unwrap().doc.spreads[0].items.len(); + h.input_mut().events.push(image()); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().doc.spreads[0].items.len(), items); + assert!(h.state().ui.status.contains("stop editing the text"), "{}", h.state().ui.status); +} + #[test] fn selected_merged_owners_paint_the_same_row_and_column_targets() { for column in [true, false] { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d70a79695..9a8e23900 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -272,11 +272,24 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest ctx.request_repaint(); ok(Value::Null) } - // What Ctrl/Cmd+V delivers from the system clipboard: text, or SVG markup copied in an - // illustration app (pasted as editable paths when no text is being edited). + // What Ctrl/Cmd+V delivers from the system clipboard: text, SVG markup copied in an + // illustration app (pasted as editable paths when no text is being edited), or an image + // (`image`: an image file as base64, pasted as a placed image). "ui.paste" => { - let Some(text) = s("text") else { return err("missing `text`") }; - app.control_synthetic.push(egui::Event::Paste(text.to_string())); + let event = match (s("text"), s("image")) { + (Some(text), None) => egui::Event::Paste(text.to_string()), + (None, Some(image)) => { + let bytes = designcraft_engine::cmd::base64_decode(image); + let Some((w, h, rgba)) = designcraft_engine::cmd::pasted_pixels(&bytes) else { + return err("`image` is not an image file that can be pasted"); + }; + let size = [w, h].map(|n| usize::try_from(n).unwrap_or(0)); + egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::from_rgba_unmultiplied(size, &rgba))) + } + (Some(_), Some(_)) => return err("give `text` or `image`, not both"), + (None, None) => return err("missing `text` or `image`"), + }; + app.control_synthetic.push(event); ctx.request_repaint(); ok(Value::Null) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 40bdfbcb3..f441e2f1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -74,7 +74,10 @@ use app::browser_import_tests; pub use app::opens_as_document; pub use clock::now_ms; pub use key_names::{drop_key_name_text, is_key_name}; -pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, PointerFn, ReadFn, Services, WindowFrameFn, WriteFn}; +pub use services::{ + ClipboardChangeFn, ClipboardFn, ClipboardImage, ClipboardImageFn, DownloadFn, Inbox, OpenAsyncFn, PointerFn, ReadFn, Services, WindowFrameFn, + WriteFn, +}; pub(crate) use state::second_window_viewport; pub use state::{CanvasCache, HiddenPanels, ImportRequest, ImportedFile, PdfExportSettings, Perf, SavedWorkspace, ScreenMode, Shown, UiState, View}; @@ -257,6 +260,9 @@ pub struct DesignApp { /// (no native menu: without it the paste keys produce no paste event), or what it held then /// (native menu). It is never pasted as text: Paste with it pastes the copied objects. object_clip_text: Option, + /// The clipboard's change count at the last object copy (native menu). While it is unchanged, + /// Paste pastes the copied objects rather than an image the clipboard held before the copy. + object_clip_change: Option, /// The marked text the system IME last sent (`None` once it commits or clears). When the /// composition ends some other way (a click, a tool switch), the IME is told to drop it. pub(crate) ime_marked: Option, diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 384a75b4b..fd529ac42 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -11,6 +11,32 @@ pub type DownloadFn = Box Result<(), String>>; pub type ClipboardFn = Box Option>; +/// An image on the system clipboard. +pub enum ClipboardImage { + /// A bitmap (a screenshot, an image copied in a browser): unpremultiplied RGBA, rows top to + /// bottom. + Pixels { width: u32, height: u32, rgba: Vec }, + /// An image file copied in a file manager: its name and contents. + File { name: String, bytes: Vec }, +} + +impl ClipboardImage { + /// An image egui delivered with Ctrl/Cmd+V (`Event::PasteImage`). + pub fn from_color_image(image: &egui::ColorImage) -> Self { + let rgba = image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); + let [w, h] = image.size.map(|n| u32::try_from(n).unwrap_or(u32::MAX)); + ClipboardImage::Pixels { width: w, height: h, rgba } + } +} + +/// The system clipboard's image, for Paste chosen from a menu. It returns within a short bound +/// even when the application that owns the clipboard is slow to answer (`None` then). +pub type ClipboardImageFn = Box Option>; + +/// A number the system raises with every copy to the clipboard (the macOS pasteboard's change +/// count), read without waiting for the clipboard's owner. +pub type ClipboardChangeFn = Box Option>; + /// Keeps the window's frame as the app needs it; called once per frame, before it is laid out. pub type WindowFrameFn = Box; @@ -40,6 +66,13 @@ pub struct Services { /// The system clipboard's text, for Paste chosen from a menu (egui only delivers it with the /// paste keys, which a native menu bar takes first). pub clipboard_text: Option, + /// The system clipboard's image (a bitmap, else the first image file of a copied file list), + /// for Paste chosen from a menu when the clipboard holds no text or only an image's address. + pub clipboard_image: Option, + /// The clipboard's change count, so Paste can tell an image copied after an object Copy from + /// one that was there before it. Needed where Copy leaves the system clipboard alone (a + /// native menu bar); `None` elsewhere. + pub clipboard_change: Option, /// Keeps the undecorated window borderless, so the content and the pointer stay aligned /// (Windows custom title bar, #1295). `None` when the window is decorated or the platform /// needs no fixing. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e886c07ab..706c1fc43 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -42,7 +42,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | | `ui.ime` | `{preedit?, active?: [start, end], commit?}` | Input method events as the system IME sends them: marked text (shown in place, `active` is the clause being converted, in characters) and the committed text (typed as one `text.insert`) | -| `ui.paste` | `{text}` | What Ctrl/Cmd+V delivers from the system clipboard: text into the text being edited, or SVG markup (vector art copied in Illustrator, VectorCraft or Figma) as editable paths in the middle of the view | +| `ui.paste` | `{text}` or `{image}` | What Ctrl/Cmd+V delivers from the system clipboard: text into the text being edited, SVG markup (vector art copied in Illustrator, VectorCraft or Figma) as editable paths in the middle of the view, or an image (`image`: an image file as base64) as an embedded image in the selected empty frame or in the middle of the view (`edit.pasteImage`) | | `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: 1–255` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, documentGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) |