mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 14:02:45 +00:00
Clipboard: transfer copied images to system clipboard and preserve media across internal paste (#6205)
This commit is contained in:
1 parent
a42c9eaab3
commit
49e777cce5
13 files changed
+415
-21
No files matched your search
@@ -68,7 +68,10 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
}
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
Some(Ok(())) => true,
|
||||
Some(Ok(())) => {
|
||||
platform::record_own();
|
||||
true
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
*slot = None;
|
||||
@@ -79,6 +82,50 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Put a copied picture on the clipboard: its pixels, with HTML and text beside it where the
|
||||
/// platform takes them. `false` when it couldn't be set.
|
||||
pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool {
|
||||
if platform::set_image(image, html, text) {
|
||||
return true;
|
||||
}
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_image_arboard(image);
|
||||
if set {
|
||||
platform::after_set_image(image, html, text);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn set_image_arboard(image: &wordcraft_ui_egui::system_clipboard::CopyImage) -> bool {
|
||||
WRITER.with(|w| {
|
||||
let Ok(mut slot) = w.try_borrow_mut() else { return false };
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
let img = arboard::ImageData { width: image.width, height: image.height, bytes: std::borrow::Cow::Borrowed(&image.rgba) };
|
||||
let set = slot.as_mut().map(|c| c.set().image(img));
|
||||
match set {
|
||||
Some(Ok(())) => {
|
||||
platform::record_own();
|
||||
true
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard image: {e}");
|
||||
*slot = None;
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the system clipboard still holds WordCraft's last copy.
|
||||
pub fn is_own() -> bool {
|
||||
platform::is_own()
|
||||
}
|
||||
|
||||
/// The clipboard's text.
|
||||
pub fn text() -> Option<String> {
|
||||
open()?.get_text().ok()
|
||||
@@ -180,6 +227,36 @@ mod platform {
|
||||
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
|
||||
}
|
||||
|
||||
const OWNER_MIME: &str = "application/x-wordcraft-owner";
|
||||
|
||||
fn owner_token() -> [u8; 16] {
|
||||
static TOKEN: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
|
||||
*TOKEN.get_or_init(|| {
|
||||
let mut b = [0u8; 16];
|
||||
let _ = getrandom::fill(&mut b);
|
||||
b
|
||||
})
|
||||
}
|
||||
|
||||
static X11_OWNER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
pub fn record_own() {
|
||||
if !wayland() {
|
||||
let win = x11::owner_window().unwrap_or(0);
|
||||
X11_OWNER.store(win, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_own() -> bool {
|
||||
if wayland() {
|
||||
if formats().iter().any(|m| m == OWNER_MIME) { read(OWNER_MIME, 16).is_some_and(|b| b == owner_token()) } else { false }
|
||||
} else {
|
||||
let current = x11::owner_window().unwrap_or(0);
|
||||
let recorded = X11_OWNER.load(std::sync::atomic::Ordering::Relaxed);
|
||||
recorded != 0 && current == recorded
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
|
||||
pub fn can_set() -> bool {
|
||||
!wayland()
|
||||
@@ -206,6 +283,7 @@ mod platform {
|
||||
sources.push(offer(mime, data));
|
||||
}
|
||||
}
|
||||
sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) });
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
@@ -215,6 +293,35 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, text: &str) -> bool {
|
||||
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
|
||||
|
||||
if !wayland() {
|
||||
return false;
|
||||
}
|
||||
let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
|
||||
let mut sources = vec![MimeSource { source: Source::Bytes(png.into()), mime_type: MimeType::Specific("image/png".into()) }];
|
||||
if !text.is_empty() {
|
||||
sources.push(offer("text/plain;charset=utf-8", text));
|
||||
}
|
||||
if !html.is_empty() {
|
||||
sources.push(offer("text/html", html));
|
||||
}
|
||||
sources.push(MimeSource { source: Source::Bytes(owner_token().to_vec().into()), mime_type: MimeType::Specific(OWNER_MIME.into()) });
|
||||
match Options::new().copy_multi(sources) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard image: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {}
|
||||
|
||||
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
@@ -341,6 +448,13 @@ mod platform {
|
||||
let reply = r.convert(target, cap, true)?;
|
||||
(reply.type_ != incr).then_some(reply.value)
|
||||
}
|
||||
|
||||
pub fn owner_window() -> Option<Window> {
|
||||
let (conn, _) = x11rb::connect(None).ok()?;
|
||||
let clipboard = atom(&conn, "CLIPBOARD")?;
|
||||
let reply = conn.get_selection_owner(clipboard).ok()?.reply().ok()?;
|
||||
(reply.owner != x11rb::NONE).then_some(reply.owner)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -415,6 +529,20 @@ mod platform {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const OWNER_FORMAT: &str = "WordCraft Copy Owner";
|
||||
|
||||
pub fn record_own() {
|
||||
let Some(format) = raw::register_format(OWNER_FORMAT) else { return };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return };
|
||||
let _ = raw::set_without_clear(format.get(), &[1u8]);
|
||||
}
|
||||
|
||||
pub fn is_own() -> bool {
|
||||
let Some(format) = raw::register_format(OWNER_FORMAT) else { return false };
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return false };
|
||||
raw::is_format_avail(format.get())
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -424,6 +552,28 @@ mod platform {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) {
|
||||
if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) {
|
||||
if let Some(format) = raw::register_format("PNG") {
|
||||
if let Ok(_open) = Clipboard::new_attempts(10) {
|
||||
let _ = raw::set_without_clear(format.get(), &png);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !html.is_empty() {
|
||||
if let Some(format) = raw::register_format("HTML Format") {
|
||||
if let Ok(_open) = Clipboard::new_attempts(10) {
|
||||
let _ = raw::set_without_clear(format.get(), html.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
record_own();
|
||||
}
|
||||
|
||||
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
let Some(format) = raw::register_format("Rich Text Format") else { return };
|
||||
@@ -479,6 +629,19 @@ mod platform {
|
||||
None
|
||||
}
|
||||
|
||||
static LAST_CHANGE_COUNT: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(-1);
|
||||
|
||||
pub fn record_own() {
|
||||
let count = NSPasteboard::generalPasteboard().changeCount();
|
||||
LAST_CHANGE_COUNT.store(count, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn is_own() -> bool {
|
||||
let count = NSPasteboard::generalPasteboard().changeCount();
|
||||
let recorded = LAST_CHANGE_COUNT.load(std::sync::atomic::Ordering::Relaxed);
|
||||
recorded >= 0 && count == recorded
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -488,6 +651,21 @@ mod platform {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn after_set_image(image: &wordcraft_ui_egui::system_clipboard::CopyImage, html: &str, _text: &str) {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
if let Ok(png) = wordcraft_ui_egui::paste_picture::png_from_rgba(image.width, image.height, image.rgba.clone()) {
|
||||
let _ = board.setData_forType(Some(&NSData::with_bytes(&png)), &NSString::from_str("public.png"));
|
||||
}
|
||||
if !html.is_empty() {
|
||||
let _ = board.setString_forType(&NSString::from_str(html), &NSString::from_str("public.html"));
|
||||
}
|
||||
record_own();
|
||||
}
|
||||
|
||||
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
|
||||
pub fn add_rtf(rtf: &str) {
|
||||
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
|
||||
@@ -530,10 +708,22 @@ mod platform {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn record_own() {}
|
||||
|
||||
pub fn is_own() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn after_set_image(_image: &wordcraft_ui_egui::system_clipboard::CopyImage, _html: &str, _text: &str) {}
|
||||
|
||||
pub fn add_rtf(_rtf: &str) {}
|
||||
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
|
||||
@@ -352,6 +352,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
|
||||
set_clipboard_image: Some(Box::new(clipboard::set_image)),
|
||||
clipboard_is_own: Some(Box::new(clipboard::is_own)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
save_prefs: Some(Box::new(try_save_prefs)),
|
||||
|
||||
+45
-5
@@ -20,6 +20,9 @@ pub struct Fragment {
|
||||
/// gives each box a new story made from these.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub parts: BTreeMap<u32, Part>,
|
||||
/// Media items referenced by objects in this fragment.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "crate::byte_store")]
|
||||
pub media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Fragment {
|
||||
@@ -30,6 +33,8 @@ impl Fragment {
|
||||
pub fn to_document(&self) -> Document {
|
||||
let mut d = Document::new();
|
||||
d.body = self.blocks.iter().cloned().map(Arc::new).collect();
|
||||
d.parts = self.parts.clone();
|
||||
d.media = self.media.clone();
|
||||
d
|
||||
}
|
||||
/// Whether the fragment holds an equation (in tables too), which a copy also puts on the
|
||||
@@ -974,7 +979,8 @@ impl Document {
|
||||
// Half a content control (one marker without the other) isn't copied.
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
let media = self.media_of_blocks(&blocks);
|
||||
Fragment { blocks, parts, media }
|
||||
}
|
||||
|
||||
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
|
||||
@@ -996,7 +1002,8 @@ impl Document {
|
||||
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
|
||||
crate::control::balance_blocks(&mut blocks);
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
let media = self.media_of_blocks(&blocks);
|
||||
Fragment { blocks, parts, media }
|
||||
}
|
||||
|
||||
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
|
||||
@@ -1038,7 +1045,8 @@ impl Document {
|
||||
pub fn body_fragment(&self) -> Fragment {
|
||||
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
|
||||
let parts = self.text_box_parts(&blocks);
|
||||
Fragment { blocks, parts }
|
||||
let media = self.media_of_blocks(&blocks);
|
||||
Fragment { blocks, parts, media }
|
||||
}
|
||||
|
||||
/// Another document's body as a fragment of this one, to insert: the media, lists, styles,
|
||||
@@ -1207,7 +1215,7 @@ impl Document {
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
blocks.iter_mut().for_each(|b| link.block(b));
|
||||
Fragment { blocks, parts }
|
||||
Fragment { blocks, parts, media: self.media.clone() }
|
||||
}
|
||||
|
||||
/// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its
|
||||
@@ -1215,7 +1223,7 @@ impl Document {
|
||||
/// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past
|
||||
/// the adopted ones.
|
||||
pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec<Block> {
|
||||
let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms);
|
||||
let Fragment { mut blocks, parts, .. } = self.adopt_in(src, keep_styles, perms);
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
blocks
|
||||
@@ -1241,6 +1249,33 @@ impl Document {
|
||||
out
|
||||
}
|
||||
|
||||
/// Media items referenced by objects in `blocks`, nested text box stories included.
|
||||
pub fn media_of_blocks(&self, blocks: &[Block]) -> BTreeMap<String, Arc<Vec<u8>>> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
let mut collect_para = |p: &Paragraph| {
|
||||
for o in &p.objects {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
keys.insert(media.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
for b in blocks {
|
||||
each_para(b, 0, &mut collect_para);
|
||||
}
|
||||
for part in self.text_box_parts(blocks).values() {
|
||||
for b in &part.blocks {
|
||||
each_para(b, 0, &mut collect_para);
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
for k in keys {
|
||||
if let Some(data) = self.media.get(&k) {
|
||||
out.insert(k, data.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn copy_blocks(&self, a: &Pos, b: &Pos) -> Vec<Block> {
|
||||
let (a, b) = order(a, b);
|
||||
let mut out = Vec::new();
|
||||
@@ -1389,6 +1424,11 @@ impl Document {
|
||||
|
||||
/// Insert a fragment at `pos`; returns the position after the pasted content.
|
||||
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
|
||||
for (k, v) in &frag.media {
|
||||
if !self.media.contains_key(k) {
|
||||
self.media.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
// Tables that would nest deeper than the limit here become their paragraphs (#1596).
|
||||
let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth());
|
||||
let mut blocks = Vec::with_capacity(frag.blocks.len());
|
||||
|
||||
@@ -326,7 +326,7 @@ fn fan_out(story: Vec<Arc<Block>>, refs: usize) -> Fragment {
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() });
|
||||
Fragment { blocks: vec![Block::Para(p)], parts }
|
||||
Fragment { blocks: vec![Block::Para(p)], parts, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -232,7 +232,8 @@ fn copy(s: &mut Session, segs: &[(Pos, Pos)]) -> CmdResult {
|
||||
blocks.push(Block::Para(para));
|
||||
}
|
||||
s.clipboard_text = rows.join("\n");
|
||||
s.clipboard = Some(Fragment { blocks, parts });
|
||||
let media = s.doc.media_of_blocks(&blocks);
|
||||
s.clipboard = Some(Fragment { blocks, parts, media });
|
||||
Ok(super::edit::copy_result(s))
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,10 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
(s.clipboard_html, s.clipboard_rtf) = match &s.clipboard {
|
||||
Some(f) => {
|
||||
let d = f.to_document();
|
||||
let mut d = f.to_document();
|
||||
if d.media.is_empty() {
|
||||
d.media = s.doc.media.clone();
|
||||
}
|
||||
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
|
||||
@@ -131,7 +131,9 @@ pub(super) fn quick_parts(s: &mut Session, v: &Value, gallery: &str) -> CmdResul
|
||||
None => {
|
||||
let e = document_entry(&s.doc, name, gallery).and_then(|i| s.doc.glossary.get(i));
|
||||
let e = e.ok_or_else(|| CmdError::Params(format!("no Quick Part `{name}`")))?;
|
||||
wordcraft_doc::edit::Fragment { blocks: e.blocks.iter().map(|b| (**b).clone()).collect(), parts: Default::default() }
|
||||
let blocks: Vec<_> = e.blocks.iter().map(|b| (**b).clone()).collect();
|
||||
let media = s.doc.media_of_blocks(&blocks);
|
||||
wordcraft_doc::edit::Fragment { blocks, parts: Default::default(), media }
|
||||
}
|
||||
};
|
||||
let at = delete_selection(s)?;
|
||||
|
||||
@@ -100,6 +100,7 @@ impl WordApp {
|
||||
..UiState::default()
|
||||
},
|
||||
services,
|
||||
clipboard_published: false,
|
||||
printing: Default::default(),
|
||||
print_feedback_seen: None,
|
||||
integrated_titlebar: false,
|
||||
|
||||
@@ -269,12 +269,16 @@ fn paste_release(app: &mut WordApp, e: &egui::Event) -> bool {
|
||||
let modifiers = &paste_release_modifiers(*modifiers, press_seen, app.canvas.paste_mods.take());
|
||||
let paste_keys = is_paste_release(*key, *modifiers);
|
||||
let pasted = std::mem::take(&mut app.canvas.pasted);
|
||||
if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() {
|
||||
if !pasted && paste_keys && app.session.math.is_none() && (app.services.clipboard_picture.is_some() || app.session.clipboard.is_some()) {
|
||||
let _ = app.run("edit.paste", json!({}));
|
||||
}
|
||||
// Mod+Alt+V with no text on the clipboard: Paste Special still opens, listing what
|
||||
// is there (a picture) or saying what it holds (#687, #833).
|
||||
if !pasted && is_paste_special_release(*key, *modifiers) && app.session.math.is_none() && app.services.clipboard_picture.is_some() {
|
||||
if !pasted
|
||||
&& is_paste_special_release(*key, *modifiers)
|
||||
&& app.session.math.is_none()
|
||||
&& (app.services.clipboard_picture.is_some() || app.session.clipboard.is_some())
|
||||
{
|
||||
let _ = app.run("edit.pasteSpecial", json!({}));
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -614,6 +614,8 @@ pub struct WordApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub services: Services,
|
||||
/// Only a successfully published copy may reuse this window's private fragment.
|
||||
pub clipboard_published: bool,
|
||||
pub printing: printing::State,
|
||||
print_feedback_seen: Option<printing::Feedback>,
|
||||
pub canvas: canvas::CanvasState,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::state::DefaultApp;
|
||||
use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing};
|
||||
use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing, system_clipboard};
|
||||
|
||||
/// Platform services injected by the host (file dialogs, file I/O).
|
||||
#[derive(Default)]
|
||||
@@ -73,6 +73,10 @@ pub struct Services {
|
||||
/// pasted equation from (#1297). `true` when the text and HTML are there; otherwise the copy's
|
||||
/// text goes through egui as usual.
|
||||
pub set_clipboard_rich: Option<Box<dyn Fn(&str, &str, &str, &str) -> bool>>,
|
||||
/// Desktop: publish one picture's pixels, HTML and text together without clearing formats.
|
||||
pub set_clipboard_image: Option<Box<dyn Fn(&system_clipboard::CopyImage, &str, &str) -> bool>>,
|
||||
/// Whether the clipboard still contains this process's last copy.
|
||||
pub clipboard_is_own: Option<Box<dyn Fn() -> bool>>,
|
||||
/// Desktop: the RTF on the system clipboard, where the platform offers it (#687).
|
||||
pub clipboard_rtf: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Desktop: the names of the formats on the system clipboard (MIME types, Windows format
|
||||
|
||||
@@ -11,6 +11,14 @@ use wordcraft_engine::cmd::paste::{MAX_PASTE_BYTES, MAX_PICTURE_BYTES};
|
||||
|
||||
use crate::{WordApp, paste_picture};
|
||||
|
||||
/// Straight RGBA8 pixels for a native image copy. Decode limits are applied before allocation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CopyImage {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The commands that paste the clipboard.
|
||||
pub(crate) fn is_paste(id: &str) -> bool {
|
||||
matches!(id, "edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial")
|
||||
@@ -30,6 +38,10 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
|
||||
return None;
|
||||
}
|
||||
let o = params.as_object_mut()?;
|
||||
if app.services.clipboard_is_own.is_some() {
|
||||
let own = app.clipboard_published && app.services.clipboard_is_own.as_ref().is_some_and(|f| f());
|
||||
o.insert("privateClipboard".into(), Value::Bool(own));
|
||||
}
|
||||
if !o.contains_key("text")
|
||||
&& let Some(text) = app.services.clipboard_text.as_ref().and_then(|f| f()).filter(|t| !t.is_empty())
|
||||
{
|
||||
@@ -56,6 +68,11 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
|
||||
if let Some(rtf) = app.services.clipboard_rtf.as_ref().and_then(|f| f()).filter(fits) {
|
||||
o.insert("rtf".into(), Value::String(rtf));
|
||||
}
|
||||
// Native bitmap/image formats are for other programs. Our own paste keeps the original
|
||||
// fragment (size, cropping and floating-picture properties), while its owner mark lasts.
|
||||
if app.session.clipboard.is_some() && o.get("privateClipboard").and_then(Value::as_bool) == Some(true) {
|
||||
o.insert("text".into(), Value::String(app.session.clipboard_text.clone()));
|
||||
}
|
||||
if id == "edit.pasteSpecial"
|
||||
&& let Some(png) = paste_picture::clipboard_png(app)
|
||||
{
|
||||
@@ -71,25 +88,99 @@ pub(crate) fn fill(app: &mut WordApp, id: &str, params: &mut Value) -> Option<Re
|
||||
/// and equations as MathML, #816; a lone equation in the MathML formats too, #1297) when the host
|
||||
/// can set them, else the text through egui. egui sets its text at the end of the frame, which
|
||||
/// would replace the rest, so only one of them runs.
|
||||
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, rtf: &str, mathml: &str) {
|
||||
pub(crate) fn put(app: &WordApp, ctx: &egui::Context, text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
|
||||
if text.is_empty() {
|
||||
// A copied picture or shape has no text, only HTML (#2276): empty the clipboard's text,
|
||||
// else Paste would find the text copied before it and paste that instead of our copy.
|
||||
if !html.is_empty() {
|
||||
if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) {
|
||||
return true;
|
||||
}
|
||||
ctx.copy_text(String::new());
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if !(html.is_empty() && rtf.is_empty()) && app.services.set_clipboard_rich.as_ref().is_some_and(|f| f(text, html, rtf, mathml)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
ctx.copy_text(text.to_string());
|
||||
false
|
||||
}
|
||||
|
||||
/// Put what Copy or Cut returned on the system clipboard (see [`put`]).
|
||||
pub(crate) fn put_result(app: &WordApp, ctx: &egui::Context, r: &Value) {
|
||||
pub(crate) fn put_result(app: &mut WordApp, ctx: &egui::Context, r: &Value) {
|
||||
let s = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("");
|
||||
put(app, ctx, s("text"), s("html"), s("rtf"), s("mathml"));
|
||||
app.clipboard_published = false;
|
||||
if app.services.set_clipboard_image.is_some()
|
||||
&& let Some(res) = copied_image(app)
|
||||
{
|
||||
match res {
|
||||
Ok(image) => {
|
||||
let set = app.services.set_clipboard_image.as_ref().map(|f| f(&image, s("html"), s("text"))).unwrap_or(false);
|
||||
if set {
|
||||
app.clipboard_published = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.status(format!("Couldn't copy the picture: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
app.clipboard_published = put(app, ctx, s("text"), s("html"), s("rtf"), s("mathml"));
|
||||
}
|
||||
|
||||
fn copied_image(app: &WordApp) -> Option<Result<CopyImage, String>> {
|
||||
use wordcraft_doc::{Block, para::InlineObject};
|
||||
let fragment = app.session.clipboard.as_ref()?;
|
||||
let [Block::Para(para)] = fragment.blocks.as_slice() else { return None };
|
||||
let [InlineObject::Image { media, .. }] = para.objects.as_slice() else { return None };
|
||||
if para.text != wordcraft_doc::para::OBJ.to_string() {
|
||||
return None;
|
||||
}
|
||||
let bytes = fragment.media.get(media).or_else(|| app.session.doc.media.get(media))?;
|
||||
Some(match wordcraft_render::metafile_size(bytes) {
|
||||
Some((w, h, _)) => {
|
||||
let rect = wordcraft_geom::Rect::new(0.0, 0.0, w, h);
|
||||
let items = [wordcraft_layout::display::Draw::Image {
|
||||
rect,
|
||||
media: media.clone(),
|
||||
crop: [0.0; 4],
|
||||
alpha: 1.0,
|
||||
alt: String::new(),
|
||||
fx: Default::default(),
|
||||
decorative: false,
|
||||
}];
|
||||
let image = wordcraft_render::render_items(&app.session.doc, &items, rect, 96.0 / 72.0, &Default::default());
|
||||
Ok(CopyImage { width: image.width as usize, height: image.height as usize, rgba: image.to_straight() })
|
||||
}
|
||||
None => decode_image(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_image(bytes: &[u8]) -> Result<CopyImage, String> {
|
||||
if bytes.len() as u64 > paste_picture::MAX_FILE_BYTES {
|
||||
return Err("the picture file is larger than 200 MB".into());
|
||||
}
|
||||
let reader = || image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| e.to_string());
|
||||
let (w, h) = reader()?.into_dimensions().map_err(|e| e.to_string())?;
|
||||
let pixels = (w as usize).checked_mul(h as usize);
|
||||
if w == 0
|
||||
|| h == 0
|
||||
|| w as usize > paste_picture::MAX_SIDE
|
||||
|| h as usize > paste_picture::MAX_SIDE
|
||||
|| pixels.is_none_or(|n| n > paste_picture::MAX_PIXELS)
|
||||
{
|
||||
return Err(format!("the picture is too large to copy ({w} × {h} pixels)"));
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(paste_picture::MAX_SIDE as u32);
|
||||
limits.max_image_height = Some(paste_picture::MAX_SIDE as u32);
|
||||
limits.max_alloc = Some((paste_picture::MAX_PIXELS as u64).saturating_mul(4));
|
||||
let mut reader = reader()?;
|
||||
reader.limits(limits);
|
||||
let rgba = reader.decode().map_err(|e| e.to_string())?.into_rgba8();
|
||||
Ok(CopyImage { width: w as usize, height: h as usize, rgba: rgba.into_raw() })
|
||||
}
|
||||
|
||||
/// Format names worth showing: not the clipboard protocol's own bookkeeping targets.
|
||||
@@ -265,7 +356,7 @@ mod tests {
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let r = a.run("edit.copy", json!({})).unwrap();
|
||||
put_result(&a, &ctx, &r);
|
||||
put_result(&mut a, &ctx, &r);
|
||||
let got_now = got.borrow().clone();
|
||||
assert_eq!(got_now.len(), 1);
|
||||
assert!(got_now[0].0.contains("<math") && got_now[0].1.contains("y=2x_s"), "{got_now:?}");
|
||||
@@ -275,7 +366,7 @@ mod tests {
|
||||
// and RTF for the programs that read that (gap-clipboard-rtf).
|
||||
a.run("select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 5}})).unwrap();
|
||||
let r = a.run("edit.copy", json!({})).unwrap();
|
||||
put_result(&a, &ctx, &r);
|
||||
put_result(&mut a, &ctx, &r);
|
||||
let got_now = got.borrow().clone();
|
||||
assert_eq!(got_now.len(), 2);
|
||||
assert!(got_now[1].0.contains("Speed") && !got_now[1].0.contains("<math") && got_now[1].1 == "Speed", "{got_now:?}");
|
||||
@@ -284,9 +375,62 @@ mod tests {
|
||||
// The equation alone: its MathML for the platform's MathML formats too (#1297).
|
||||
a.run("select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 9}})).unwrap();
|
||||
let r = a.run("edit.copy", json!({})).unwrap();
|
||||
put_result(&a, &ctx, &r);
|
||||
put_result(&mut a, &ctx, &r);
|
||||
let got_now = got.borrow().clone();
|
||||
assert_eq!(got_now.len(), 3);
|
||||
assert!(got_now[2].2.starts_with("<math") && got_now[2].2.contains("<msub>") && got_now[2].1 == "y=2x_s", "{got_now:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_an_image_publishes_image_and_html_to_system_clipboard() {
|
||||
let got_image = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(usize, usize, String, String)>::new()));
|
||||
let sink = got_image.clone();
|
||||
let services = Services {
|
||||
set_clipboard_image: Some(Box::new(move |img, html, text| {
|
||||
sink.borrow_mut().push((img.width, img.height, html.to_string(), text.to_string()));
|
||||
true
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = app(services);
|
||||
let png = paste_picture::png_from_rgba(2, 2, vec![255; 16]).unwrap();
|
||||
let data = wordcraft_engine::cmd::insert::base64_encode(&png);
|
||||
a.run("insert.picture", json!({"data": data})).unwrap();
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let r = a.run("edit.copy", json!({})).unwrap();
|
||||
put_result(&mut a, &ctx, &r);
|
||||
assert!(a.clipboard_published);
|
||||
let got = got_image.borrow().clone();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].0, 2);
|
||||
assert_eq!(got[0].1, 2);
|
||||
assert!(got[0].2.contains("<img src=\"data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_own_image_uses_private_clipboard_fragment() {
|
||||
let services =
|
||||
Services { set_clipboard_image: Some(Box::new(|_, _, _| true)), clipboard_is_own: Some(Box::new(|| true)), ..Default::default() };
|
||||
let mut a = app(services);
|
||||
let png = paste_picture::png_from_rgba(2, 2, vec![255; 16]).unwrap();
|
||||
let data = wordcraft_engine::cmd::insert::base64_encode(&png);
|
||||
a.run("insert.picture", json!({"data": data})).unwrap();
|
||||
a.run("select.all", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let r = a.run("edit.copy", json!({})).unwrap();
|
||||
put_result(&mut a, &ctx, &r);
|
||||
assert!(a.clipboard_published);
|
||||
|
||||
a.run("document.setText", json!({"text": ""})).unwrap();
|
||||
assert_eq!(a.session.doc.body.len(), 1);
|
||||
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).is_empty());
|
||||
|
||||
let mut params = json!({});
|
||||
fill(&mut a, "edit.paste", &mut params);
|
||||
assert_eq!(params.get("privateClipboard"), Some(&Value::Bool(true)));
|
||||
a.run("edit.paste", params).unwrap();
|
||||
|
||||
assert!(!a.session.doc.media.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,7 @@ impl WordApp {
|
||||
file_browser,
|
||||
info_editing,
|
||||
shown_document,
|
||||
clipboard_published: _,
|
||||
} = self;
|
||||
let UiState {
|
||||
// App-wide: preferences, recent lists, and the settings the session owns while
|
||||
|
||||
Reference in new issue
Block a user