Merge pull request #2665 from dotCooCoo/feat/paste-clipboard-image

Paste: images on the system clipboard paste as embedded images
This commit is contained in:
FredApps authored and GitHub committed 2026-11-12 18:03:55 +01:00
commit 31d4c7f7f4
16 files changed
+841 -24

No files matched your search

+5 -3
View File
@@ -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.
+135
View File
@@ -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<ClipboardImage> {
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<T: Send + 'static>(busy: &'static AtomicBool, wait: Duration, read: impl FnOnce() -> Option<T> + Send + 'static) -> Option<T> {
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> {
i64::try_from(objc2_app_kit::NSPasteboard::generalPasteboard().changeCount()).ok()
}
fn read_now() -> Option<ClipboardImage> {
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<ClipboardImage> {
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());
}
}
+4
View File
@@ -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()
+3
View File
@@ -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());
@@ -380,5 +382,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_base64, check_pasted_size, pasted_pixels, pasted_png};
pub use paste_svg::is_svg_markup;
pub(crate) use place_text::autoflow as place_text_autoflow;
+318
View File
@@ -0,0 +1,318 @@
//! 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<CommandSpec> {
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 one point per pixel, as File › Place sizes an image, 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(())
}
/// Whether an image file given as base64 is small enough to paste, checked before it is
/// decoded; the error says why not.
pub fn check_pasted_base64(b64: &str) -> std::result::Result<(), String> {
check_pasted_base64_len(b64.len())
}
fn check_pasted_base64_len(len: usize) -> std::result::Result<(), String> {
if len / 4 * 3 > MAX_PASTED_BYTES {
return Err(format!("the image is larger than {} MB", MAX_PASTED_BYTES >> 20));
}
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<u8>) -> std::result::Result<Vec<u8>, 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<u8>)> {
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<Value> {
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")),
};
check_pasted_base64(b64).map_err(|e| bad(ID, e))?;
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))?;
// The header can be whole while the pixels after it are cut off: decode the whole raster so
// a damaged image is refused instead of stored.
if designcraft_images::decode_rgba(&bytes).is_none() {
return Err(bad(ID, "the image on the clipboard is damaged and can't be read"));
}
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 place(s, &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: one point per pixel (`designcraft_images::natural_size`), 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);
place(s, &json!({"base64": b64, "name": name, "spread": spread, "x": center.x - w / 2.0, "y": center.y - h / 2.0}))
}
/// Runs `file.place` inside this command. Only `edit.pasteImage` stays in the journal, so a
/// replay of the journal places the image once.
fn place(s: &mut Session, params: &Value) -> Result<Value> {
let journal = s.journal.len();
let r = s.execute("file.place", params);
s.journal.truncate(journal);
r
}
#[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<u8> {
let rgba: Vec<u8> = (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<u8> {
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"<svg xmlns='http://www.w3.org/2000/svg'/>")}), "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 base64_past_the_file_limit_is_refused_by_its_length() {
let limit = MAX_PASTED_BYTES / 3 * 4;
assert!(check_pasted_base64_len(limit).is_ok());
let e = check_pasted_base64_len(limit + 4).unwrap_err();
assert!(e.contains("larger than 256 MB"), "{e}");
}
#[test]
fn a_damaged_image_with_a_whole_header_is_refused() {
let mut s = doc();
let whole = png(40, 30);
let cut = &whole[..whole.len() / 2];
assert_eq!(raster_size(cut), Some((40, 30)), "the header still reads");
let (undo, assets) = (s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.assets.len());
let e = s.execute(ID, &json!({"base64": base64_encode(cut)})).unwrap_err().to_string();
assert!(e.contains("damaged"), "{e}");
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.doc.assets.len()), (undo, assets), "nothing stored");
}
#[test]
fn each_paste_is_one_journal_entry_and_replays_once() {
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 start = s.journal.len();
let b64 = base64_encode(&png(10, 10));
s.execute(ID, &json!({"base64": b64, "at": [500, 500]})).unwrap();
s.execute(ID, &json!({"base64": b64, "at": [500, 500]})).unwrap();
let entries = s.journal[start..].to_vec();
assert_eq!(entries.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), [ID, ID], "the frame and the new-frame paths");
let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len();
let mut t = doc();
t.execute("frame.create", &json!({"rect": [100, 100, 200, 300]})).unwrap();
t.execute("selection.set", &json!({"ids": [frame]})).unwrap();
for (id, p) in &entries {
t.execute(id, p).unwrap();
}
assert_eq!(items(&t), items(&s), "the replay places each image once");
}
#[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");
}
}
+9
View File
@@ -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<u8>) -> Option<Vec<u8>> {
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) {
+1
View File
@@ -114,6 +114,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,
+93 -9
View File
@@ -1,28 +1,65 @@
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);
}
// Empty text counts as no text, so an image beside it still pastes.
if params.get("text").is_none()
&& let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f())
&& let Some(text) = self.services.clipboard_text.as_mut().and_then(|f| f()).filter(|t| !t.is_empty())
&& let Some(o) = params.as_object_mut()
{
o.insert("text".into(), Value::String(text));
}
let unchanged = self.clipboard_unchanged_since_object_copy();
// 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(|t| t.trim().is_empty() || is_bare_address(t))
&& 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 +69,54 @@ impl DesignApp {
o.entry("spread").or_insert(spread);
o.entry("at").or_insert(json!([at.x, at.y]));
}
params
Pasted::Params(params)
}
/// Nothing was copied to the clipboard since objects were copied here (native menu, where
/// the system raises a change count with every copy).
fn clipboard_unchanged_since_object_copy(&mut self) -> bool {
match self.object_clip_change {
Some(at_copy) => self.services.clipboard_change.as_mut().and_then(|f| f()) == Some(at_copy),
None => true,
}
}
/// 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. An image the
/// clipboard held before objects were copied here (native menu) is not pasted: the menu's
/// Paste pastes those objects.
pub(crate) fn paste_clipboard_image(&mut self, image: ClipboardImage) -> Result<Value, String> {
let user = !self.in_control && !self.control_input_frame.load(std::sync::atomic::Ordering::Relaxed);
if user && self.session.clipboard.is_some() && self.object_clip_change.is_some() && self.clipboard_unchanged_since_object_copy() {
return Ok(Value::Null);
}
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.
+13 -2
View File
@@ -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,18 @@ 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());
// Empty text counts as no text, as Paste reads it.
self.object_clip_text = self.services.clipboard_text.as_mut().and_then(|f| f()).filter(|t| !t.is_empty());
self.object_clip_change = self.services.clipboard_change.as_mut().and_then(|f| f());
None
}
};
+15 -1
View File
@@ -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<egui::Event>, 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;
+177
View File
@@ -1051,6 +1051,183 @@ 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<String>,
image: Option<(u32, u32)>,
change: i64,
}
type FakeClipboard = std::rc::Rc<std::cell::RefCell<FakeClip>>;
/// 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)));
// Empty text beside the image counts as no text.
copy_elsewhere(&clip, Some(""), Some((12, 9)));
crate::menus::activate(&mut app, "edit.paste", &Value::Null);
assert_eq!(pasted_image(&app).map(|(px, _)| px), Some((12, 9)));
// 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");
// Empty text beside the older image counts as no text at the copy too.
copy_elsewhere(&clip, Some(""), Some((7, 7)));
app.run("selection.set", json!({"ids": [id]})).unwrap();
crate::menus::activate(&mut app, "edit.copy", &Value::Null);
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 + 1, "the copied text frame is pasted");
assert_eq!(pasted_image(&app), None);
}
#[test]
fn a_paste_image_event_after_an_object_copy_does_not_paste_the_older_image() {
// A native menu: Copy leaves the older image on the system clipboard.
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);
app.run("selection.set", json!({"ids": []})).unwrap();
let mut h = harness(app);
let image = |w, h| egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([w, h], egui::Color32::RED)));
let items = h.state().session.doc().unwrap().doc.spreads[0].items.len();
h.input_mut().events.push(image(40, 30));
h.run_steps(2);
assert_eq!(h.state().session.doc().unwrap().doc.spreads[0].items.len(), items, "the older image is not pasted");
// An image copied since then pastes.
copy_elsewhere(&clip, None, Some((20, 10)));
h.input_mut().events.push(image(20, 10));
h.run_steps(2);
assert_eq!(pasted_image(h.state()).map(|(px, _)| px), Some((20, 10)));
}
#[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 a_paste_keys_image_past_the_size_limit_is_refused_without_copying_its_pixels() {
// The size says 72 million pixels; the four pixels it holds are not copied.
let big = egui::ColorImage { size: [9000, 8000], source_size: egui::vec2(9000.0, 8000.0), pixels: vec![egui::Color32::RED; 4] };
let image = crate::ClipboardImage::from_color_image(&big);
let crate::ClipboardImage::Pixels { width, height, rgba } = &image else { panic!("a bitmap") };
assert_eq!((*width, *height, rgba.len()), (9000, 8000, 0));
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let items = app.session.doc().unwrap().doc.spreads[0].items.len();
assert!(app.paste_clipboard_image(image).is_err());
assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items);
assert!(app.ui.status.contains("64 million"), "{}", app.ui.status);
// An image within the limit keeps its pixels.
let small = crate::ClipboardImage::from_color_image(&egui::ColorImage::filled([3, 2], egui::Color32::RED));
let crate::ClipboardImage::Pixels { width, height, rgba } = small else { panic!("a bitmap") };
assert_eq!((width, height), (3, 2));
assert_eq!(rgba, [255, 0, 0, 255].repeat(6));
}
#[test]
fn selected_merged_owners_paint_the_same_row_and_column_targets() {
for column in [true, false] {
+20 -4
View File
@@ -272,11 +272,27 @@ 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)) => {
if let Err(e) = designcraft_engine::cmd::check_pasted_base64(image) {
return err(format!("`image`: {e}"));
}
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)
}
+7 -1
View File
@@ -75,7 +75,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};
@@ -260,6 +263,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<String>,
/// 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<i64>,
/// 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<String>,
+38
View File
@@ -11,6 +11,37 @@ pub type DownloadFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>;
/// 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<u8> },
/// An image file copied in a file manager: its name and contents.
File { name: String, bytes: Vec<u8> },
}
impl ClipboardImage {
/// An image egui delivered with Ctrl/Cmd+V (`Event::PasteImage`). The pixels of an image
/// past the paste limit are not copied, and pasting it is refused by its size.
pub fn from_color_image(image: &egui::ColorImage) -> Self {
let [w, h] = image.size.map(|n| u32::try_from(n).unwrap_or(u32::MAX));
let rgba = if designcraft_engine::cmd::check_pasted_size(w, h).is_ok() {
image.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect()
} else {
Vec::new()
};
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<dyn FnMut() -> Option<ClipboardImage>>;
/// 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<dyn FnMut() -> Option<i64>>;
/// Keeps the window's frame as the app needs it; called once per frame, before it is laid out.
pub type WindowFrameFn = Box<dyn FnMut()>;
@@ -40,6 +71,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<ClipboardFn>,
/// 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<ClipboardImageFn>,
/// 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<ClipboardChangeFn>,
/// 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.
+1 -1
View File
@@ -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) |
+2 -3
View File
@@ -494,9 +494,8 @@ Clipboard:
freshness, owner/seat changes, empty/absent offers, self-paste, canceled/slow transfers,
password exclusion and no unchanged-selection reclaim on wlroots/KWin/Mutter. Native
capability evidence is still required; compile-only is not acceptance.
- `Event::PasteImage` is new but DesignCraft has no image-paste contract. Leave it
unhandled rather than inventing a placement path. Add a test that it does not panic and
does not leak into text paste.
- `Event::PasteImage` (an image on the clipboard with no text) pastes through `edit.pasteImage`: in the
selected empty frame, else centered in the view, and never into text being edited.
Keyboard layouts: after the fork pin, `RawInput::keyboard_layout_is_latin` and
`RawInput::latin_layout_chars` (E19) must preserve tool and command shortcuts on Russian,